diff --git a/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala b/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala index 5a13f322df..87aeb665a8 100644 --- a/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala +++ b/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala @@ -6,7 +6,11 @@ import zio.ZLayer import org.knora.webapi.auth.JWTService import org.knora.webapi.config.AppConfig import org.knora.webapi.config.AppConfigForTestContainers +import org.knora.webapi.messages.StringFormatter import org.knora.webapi.routing.ApiRoutes +import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceManager import org.knora.webapi.store.cache.api.CacheService import org.knora.webapi.store.cache.impl.CacheServiceInMemImpl @@ -37,8 +41,12 @@ object LayersTest { with CacheServiceManager with HttpServer with IIIFServiceManager + with IriConverter with RepositoryUpdater + with ResourceInfoRepo + with RestResourceInfoService with State + with StringFormatter with TestClientService with TriplestoreService with TriplestoreServiceManager @@ -51,8 +59,12 @@ object LayersTest { CacheServiceManager.layer, HttpServer.layer, IIIFServiceManager.layer, + IriConverter.layer, RepositoryUpdater.layer, + ResourceInfoRepo.layer, + RestResourceInfoService.layer, State.layer, + StringFormatter.test, TestClientService.layer, TriplestoreServiceHttpConnectorImpl.layer, TriplestoreServiceManager.layer diff --git a/webapi/src/it/scala/org/knora/webapi/e2e/v1/ResourcesV1R2RSpec.scala b/webapi/src/it/scala/org/knora/webapi/e2e/v1/ResourcesV1R2RSpec.scala index 59613f3c2b..0f75873c81 100644 --- a/webapi/src/it/scala/org/knora/webapi/e2e/v1/ResourcesV1R2RSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/e2e/v1/ResourcesV1R2RSpec.scala @@ -58,7 +58,7 @@ class ResourcesV1R2RSpec extends R2RSpec { private val resourcesPathV1 = DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV1(routeData).makeRoute) private val resourcesPathV2 = - DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData).makeRoute) + DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData, null).makeRoute) private val valuesPathV1 = DSPApiDirectives.handleErrors(system, appConfig)(new ValuesRouteV1(routeData).makeRoute) diff --git a/webapi/src/it/scala/org/knora/webapi/e2e/v2/JSONLDHandlingV2R2RSpec.scala b/webapi/src/it/scala/org/knora/webapi/e2e/v2/JSONLDHandlingV2R2RSpec.scala index 57f71bade1..5add431191 100644 --- a/webapi/src/it/scala/org/knora/webapi/e2e/v2/JSONLDHandlingV2R2RSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/e2e/v2/JSONLDHandlingV2R2RSpec.scala @@ -25,7 +25,7 @@ import org.knora.webapi.routing.v2.ResourcesRouteV2 */ class JSONLDHandlingV2R2RSpec extends R2RSpec { - private val resourcesPath = new ResourcesRouteV2(routeData).makeRoute + private val resourcesPath = new ResourcesRouteV2(routeData, null).makeRoute implicit def default(implicit system: ActorSystem): RouteTestTimeout = RouteTestTimeout( appConfig.defaultTimeoutAsDuration diff --git a/webapi/src/it/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala b/webapi/src/it/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala index 818a1c79e2..d8cbb70df0 100644 --- a/webapi/src/it/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala @@ -66,7 +66,7 @@ class OntologyV2R2RSpec extends R2RSpec { private val ontologiesPath = DSPApiDirectives.handleErrors(system, appConfig)(new OntologiesRouteV2(routeData).makeRoute) private val resourcesPath = - DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData).makeRoute) + DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData, null).makeRoute) implicit def default(implicit system: ActorSystem): RouteTestTimeout = RouteTestTimeout( appConfig.defaultTimeoutAsDuration diff --git a/webapi/src/it/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala b/webapi/src/it/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala index 21b321b41b..17e340cbb5 100644 --- a/webapi/src/it/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala @@ -54,7 +54,7 @@ class SearchRouteV2R2RSpec extends R2RSpec { private val searchPath = DSPApiDirectives.handleErrors(system, appConfig)(new SearchRouteV2(routeData).makeRoute) private val resourcePath = - DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData).makeRoute) + DSPApiDirectives.handleErrors(system, appConfig)(new ResourcesRouteV2(routeData, null).makeRoute) private val standoffPath = DSPApiDirectives.handleErrors(system, appConfig)(new StandoffRouteV2(routeData).makeRoute) private val valuesPath = diff --git a/webapi/src/it/scala/org/knora/webapi/slice/resourceinfo/api/IriConverterLiveSpec.scala b/webapi/src/it/scala/org/knora/webapi/slice/resourceinfo/api/IriConverterLiveSpec.scala new file mode 100644 index 0000000000..27a3c20759 --- /dev/null +++ b/webapi/src/it/scala/org/knora/webapi/slice/resourceinfo/api/IriConverterLiveSpec.scala @@ -0,0 +1,22 @@ +package org.knora.webapi.slice.resourceinfo.api + +import org.knora.webapi.messages.StringFormatter +import org.knora.webapi.slice.resourceinfo.domain +import org.knora.webapi.slice.resourceinfo.domain.{InternalIri, IriConverter} +import zio.test._ + +object IriConverterLiveSpec extends ZIOSpecDefault { + + def spec = suite("IriConverterLive")( + test("should not convert the projectIri") { + for { + internal <- IriConverter.asInternalIri("http://project-iri") + } yield assertTrue(internal == domain.InternalIri("http://project-iri")) + }, + test("should convert a resourceClassIri") { + for { + internal <- IriConverter.asInternalIri("http://0.0.0.0:3333/ontology/0001/anything/v2#Thing") + } yield assertTrue(internal == InternalIri("http://www.knora.org/ontology/0001/anything#Thing")) + } + ).provide(IriConverter.layer, StringFormatter.test) +} diff --git a/webapi/src/it/scala/org/knora/webapi/store/triplestore/upgrade/plugins/UpgradePluginSpec.scala b/webapi/src/it/scala/org/knora/webapi/store/triplestore/upgrade/plugins/UpgradePluginSpec.scala index 5e5540d903..5954a79500 100644 --- a/webapi/src/it/scala/org/knora/webapi/store/triplestore/upgrade/plugins/UpgradePluginSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/store/triplestore/upgrade/plugins/UpgradePluginSpec.scala @@ -8,9 +8,12 @@ package org.knora.webapi.store.triplestore.upgrade.plugins import com.typesafe.scalalogging.Logger import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike - +import scala.util.Failure +import scala.util.Success import java.io.BufferedInputStream +import java.io.ByteArrayInputStream import java.io.FileInputStream +import scala.util.Using import org.knora.webapi.messages.util.ErrorHandlingMap import org.knora.webapi.messages.util.rdf._ @@ -37,6 +40,18 @@ abstract class UpgradePluginSpec extends AnyWordSpecLike with Matchers { rdfModel } + /** + * Parses a TriG String and returns it as an [[RdfModel]]. + * + * @param s the [[String]] content of a "TriG file". + * @return an [[RdfModel]]. + */ + def stringToModel(s: String): RdfModel = + Using(new ByteArrayInputStream(s.getBytes))(rdfFormatUtil.inputStreamToRdfModel(_, TriG)) match { + case Success(value) => value + case Failure(e) => throw new IllegalArgumentException("Invalid model", e) + } + /** * Wraps expected SPARQL SELECT results in a [[SparqlSelectResultBody]]. * 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 b2e5b8902c..f0c8f835a3 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -8,6 +8,8 @@ package org.knora.webapi.config import com.typesafe.config.ConfigFactory import zio._ import zio.config._ +import zio.config.magnolia._ +import zio.config.typesafe._ import java.nio.file.Files import java.nio.file.Path @@ -19,13 +21,9 @@ import scala.util.Success import scala.util.Try import dsp.errors.FileWriteException -import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.util.rdf.RdfFeatureFactory import org.knora.webapi.util.cache.CacheUtil -import typesafe._ -import magnolia._ - /** * Represents the configuration as defined in application.conf. */ @@ -272,7 +270,6 @@ object AppConfig { for { c <- configFromSource.orDie _ <- ZIO.attempt(RdfFeatureFactory.init(c)).orDie // needs early init before first usage - _ <- ZIO.attempt(StringFormatter.init(c)).orDie // needs early init before first usage } yield c }.tap(_ => ZIO.logInfo(">>> AppConfig Live Initialized <<<")) @@ -283,8 +280,7 @@ object AppConfig { ZLayer { for { c <- configFromSource.orDie - _ <- ZIO.attempt(RdfFeatureFactory.init(c)).orDie // needs early init before first usage - _ <- ZIO.attempt(StringFormatter.initForTest()).orDie // needs early init before first usage + _ <- ZIO.attempt(RdfFeatureFactory.init(c)).orDie // needs early init before first usage } yield c }.tap(_ => ZIO.logInfo(">>> AppConfig Test Initialized <<<")) diff --git a/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala b/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala index 9cf0e36383..9cc7d26955 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/HttpServerZ.scala @@ -4,24 +4,28 @@ */ package org.knora.webapi.core +import zhttp.http.HttpApp import zhttp.service.Server import zio.ZLayer import zio._ import org.knora.webapi.config.AppConfig import org.knora.webapi.routing.admin.ProjectsRouteZ +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute object HttpServerZ { - val layer: ZLayer[AppRouter & AppConfig & State & ProjectsRouteZ, Nothing, Unit] = - ZLayer { - for { - appConfig <- ZIO.service[AppConfig] - projectsRoute <- ZIO.service[ProjectsRouteZ] - r = projectsRoute.route - port = appConfig.knoraApi.externalZioPort - _ <- Server.start(port, r).forkDaemon - _ <- ZIO.logInfo(">>> Acquire ZIO HTTP Server <<<") - } yield () - } + private val apiRoutes: URIO[ResourceInfoRoute with ProjectsRouteZ, HttpApp[Any, Nothing]] = for { + projectsRoute <- ZIO.service[ProjectsRouteZ].map(_.route) + riRoute <- ZIO.service[ResourceInfoRoute].map(_.route) + } yield projectsRoute ++ riRoute + + val layer: ZLayer[ResourceInfoRoute with ProjectsRouteZ with AppConfig, Nothing, Unit] = ZLayer { + for { + port <- ZIO.service[AppConfig].map(_.knoraApi.externalZioPort) + routes <- apiRoutes + _ <- Server.start(port, routes).forkDaemon + _ <- ZIO.logInfo(">>> Acquire ZIO HTTP Server <<<") + } yield () + } } diff --git a/webapi/src/main/scala/org/knora/webapi/core/InstrumentationHttpServer.scala b/webapi/src/main/scala/org/knora/webapi/core/InstrumentationHttpServer.scala index b8ca62820e..3f6113ea3f 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/InstrumentationHttpServer.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/InstrumentationHttpServer.scala @@ -1,3 +1,8 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + package org.knora.webapi.core import zhttp.service.Server 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 b094819d48..49251584a8 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -10,8 +10,13 @@ import zio.ZLayer import org.knora.webapi.auth.JWTService import org.knora.webapi.config.AppConfig +import org.knora.webapi.messages.StringFormatter import org.knora.webapi.routing.ApiRoutes import org.knora.webapi.routing.admin.ProjectsRouteZ +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute +import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceManager import org.knora.webapi.store.cache.api.CacheService import org.knora.webapi.store.cache.impl.CacheServiceInMemImpl @@ -53,17 +58,22 @@ object LayersLive { ApiRoutes.layer, AppConfig.live, AppRouter.layer, - CacheServiceManager.layer, CacheServiceInMemImpl.layer, + CacheServiceManager.layer, HttpServer.layer, HttpServerZ.layer, // this is the new ZIO HTTP server layer IIIFServiceManager.layer, IIIFServiceSipiImpl.layer, + IriConverter.layer, JWTService.layer, + ProjectsRouteZ.layer, RepositoryUpdater.layer, + ResourceInfoRepo.layer, + ResourceInfoRoute.layer, + RestResourceInfoService.layer, State.layer, - TriplestoreServiceManager.layer, + StringFormatter.live, TriplestoreServiceHttpConnectorImpl.layer, - ProjectsRouteZ.layer + TriplestoreServiceManager.layer ) } diff --git a/webapi/src/main/scala/org/knora/webapi/instrumentation/index/IndexApp.scala b/webapi/src/main/scala/org/knora/webapi/instrumentation/index/IndexApp.scala index 0b66f8ff6f..e138840015 100644 --- a/webapi/src/main/scala/org/knora/webapi/instrumentation/index/IndexApp.scala +++ b/webapi/src/main/scala/org/knora/webapi/instrumentation/index/IndexApp.scala @@ -1,3 +1,8 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + package org.knora.webapi.instrumentation.index import zhttp.html.Html diff --git a/webapi/src/main/scala/org/knora/webapi/instrumentation/prometheus/PrometheusApp.scala b/webapi/src/main/scala/org/knora/webapi/instrumentation/prometheus/PrometheusApp.scala index ecfcaba405..ec53e0efb7 100644 --- a/webapi/src/main/scala/org/knora/webapi/instrumentation/prometheus/PrometheusApp.scala +++ b/webapi/src/main/scala/org/knora/webapi/instrumentation/prometheus/PrometheusApp.scala @@ -1,3 +1,8 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + package org.knora.webapi.instrumentation.prometheus import zhttp.http._ diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 08ed3e7604..71574203a8 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -13,6 +13,7 @@ import com.google.gwt.safehtml.shared.UriUtils._ import com.typesafe.scalalogging.Logger import org.apache.commons.lang3.StringUtils import spray.json._ +import zio.ZLayer import java.nio.ByteBuffer import java.time._ @@ -311,6 +312,16 @@ object StringFormatter { creationFun() }) ) + + val live: ZLayer[AppConfig, Nothing, StringFormatter] = ZLayer.fromFunction { appConfig: AppConfig => + StringFormatter.init(appConfig) + StringFormatter.getGeneralInstance + } + + val test: ZLayer[Any, Nothing, StringFormatter] = ZLayer.fromFunction { () => + StringFormatter.initForTest() + StringFormatter.getGeneralInstance + } } /** @@ -345,6 +356,8 @@ sealed trait SmartIri extends Ordered[SmartIri] with KnoraContentV2[SmartIri] { */ def toSparql: String + def toIri: IRI = toString + /** * Returns `true` if this is a Knora data or definition IRI. */ @@ -485,6 +498,8 @@ sealed trait SmartIri extends Ordered[SmartIri] with KnoraContentV2[SmartIri] { */ override def toOntologySchema(targetSchema: OntologySchema): SmartIri + def internalIri: IRI = toOntologySchema(InternalSchema).toIri + /** * Constructs a short prefix label for the ontology that the IRI belongs to. */ 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 9232b704ae..2c466648d7 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -15,38 +15,13 @@ import org.knora.webapi.config.AppConfig import org.knora.webapi.core import org.knora.webapi.core.ActorSystem import org.knora.webapi.core.AppRouter +import org.knora.webapi.core.State import org.knora.webapi.http.directives.DSPApiDirectives import org.knora.webapi.http.version.ServerVersion -import org.knora.webapi.routing.AroundDirectives -import org.knora.webapi.routing.HealthRoute -import org.knora.webapi.routing.KnoraRouteData -import org.knora.webapi.routing.RejectingRoute -import org.knora.webapi.routing.VersionRoute -import org.knora.webapi.routing.admin.FilesRouteADM -import org.knora.webapi.routing.admin.GroupsRouteADM -import org.knora.webapi.routing.admin.ListsRouteADM -import org.knora.webapi.routing.admin.PermissionsRouteADM -import org.knora.webapi.routing.admin.ProjectsRouteADM -import org.knora.webapi.routing.admin.StoreRouteADM -import org.knora.webapi.routing.admin.UsersRouteADM -import org.knora.webapi.routing.v1.AssetsRouteV1 -import org.knora.webapi.routing.v1.AuthenticationRouteV1 -import org.knora.webapi.routing.v1.CkanRouteV1 -import org.knora.webapi.routing.v1.ListsRouteV1 -import org.knora.webapi.routing.v1.ProjectsRouteV1 -import org.knora.webapi.routing.v1.ResourceTypesRouteV1 -import org.knora.webapi.routing.v1.ResourcesRouteV1 -import org.knora.webapi.routing.v1.SearchRouteV1 -import org.knora.webapi.routing.v1.StandoffRouteV1 -import org.knora.webapi.routing.v1.UsersRouteV1 -import org.knora.webapi.routing.v1.ValuesRouteV1 -import org.knora.webapi.routing.v2.AuthenticationRouteV2 -import org.knora.webapi.routing.v2.ListsRouteV2 -import org.knora.webapi.routing.v2.OntologiesRouteV2 -import org.knora.webapi.routing.v2.ResourcesRouteV2 -import org.knora.webapi.routing.v2.SearchRouteV2 -import org.knora.webapi.routing.v2.StandoffRouteV2 -import org.knora.webapi.routing.v2.ValuesRouteV2 +import org.knora.webapi.routing.admin._ +import org.knora.webapi.routing.v1._ +import org.knora.webapi.routing.v2._ +import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService trait ApiRoutes { val routes: Route @@ -57,7 +32,8 @@ object ApiRoutes { /** * All routes composed together. */ - val layer: ZLayer[ActorSystem & AppRouter & core.State & AppConfig, Nothing, ApiRoutes] = + val layer + : ZLayer[State with RestResourceInfoService with AppConfig with AppRouter with ActorSystem, Nothing, ApiRoutes] = ZLayer { for { sys <- ZIO.service[ActorSystem] @@ -70,7 +46,7 @@ object ApiRoutes { appConfig = appConfig ) ) - runtime <- ZIO.runtime[core.State] + runtime <- ZIO.runtime[core.State with RestResourceInfoService] } yield ApiRoutesImpl(routeData, runtime, appConfig) } } @@ -82,8 +58,11 @@ object ApiRoutes { * ALL requests go through each of the routes in ORDER. * The FIRST matching route is used for handling a request. */ -private final case class ApiRoutesImpl(routeData: KnoraRouteData, runtime: Runtime[core.State], appConfig: AppConfig) - extends ApiRoutes +private final case class ApiRoutesImpl( + routeData: KnoraRouteData, + runtime: Runtime[core.State with RestResourceInfoService], + appConfig: AppConfig +) extends ApiRoutes with AroundDirectives { val routes = @@ -108,7 +87,7 @@ private final case class ApiRoutesImpl(routeData: KnoraRouteData, runtime: Runti new ProjectsRouteV1(routeData).makeRoute ~ new OntologiesRouteV2(routeData).makeRoute ~ new SearchRouteV2(routeData).makeRoute ~ - new ResourcesRouteV2(routeData).makeRoute ~ + new ResourcesRouteV2(routeData, runtime).makeRoute ~ new ValuesRouteV2(routeData).makeRoute ~ new StandoffRouteV2(routeData).makeRoute ~ new ListsRouteV2(routeData).makeRoute ~ diff --git a/webapi/src/main/scala/org/knora/webapi/routing/IndexApp.scala b/webapi/src/main/scala/org/knora/webapi/routing/IndexApp.scala deleted file mode 100644 index 06b54cbc52..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/routing/IndexApp.scala +++ /dev/null @@ -1,22 +0,0 @@ -package org.knora.webapi.routing - -import zhttp.html._ -import zhttp.http._ - -/** - * Provides the '/' endpoint serving a small index page. - */ -object IndexApp { - - def apply(): HttpApp[Any, Nothing] = - Http.collect[Request] { case Method.GET -> !! => Response.html(Html.fromString(indexPage)) } - - private val indexPage = - """ - |DSP-API public routes - | - |

nothing yet

- |""".stripMargin - -} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala index 89ed8990d2..e45c935b12 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala @@ -198,6 +198,18 @@ object RouteUtilV2 { projectIriStr.toSmartIriWithErr(throw BadRequestException(s"Invalid project IRI: $projectIriStr")) } + /** + * Gets the project IRI specified in a Knora-specific HTTP header. + * + * @param ctx the akka-http [[RequestContext]]. + * @return the [[Try]] contains the specified project IRI, or if invalid a BadRequestException + * @throws [[BadRequestException]] if project was not provided in the header + */ + def getRequiredProjectFromHeader(ctx: RequestContext)(implicit stringFormatter: StringFormatter): SmartIri = + getProject(ctx).getOrElse( + throw BadRequestException(s"This route requires the request header ${RouteUtilV2.PROJECT_HEADER}") + ) + /** * Sends a message to a responder and completes the HTTP request by returning the response as RDF using content negotiation. * 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 deadbfe32d..8770117dc8 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 @@ -5,9 +5,22 @@ package org.knora.webapi.routing.v2 +import akka.http.scaladsl.model.ContentTypes.`application/json` +import akka.http.scaladsl.model.HttpEntity +import akka.http.scaladsl.model.HttpResponse +import akka.http.scaladsl.model.StatusCodes.InternalServerError +import akka.http.scaladsl.model.StatusCodes.OK import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.PathMatcher +import akka.http.scaladsl.server.RequestContext import akka.http.scaladsl.server.Route +import zio.Exit +import zio.Exit.Failure +import zio.Exit.Success +import zio.Runtime +import zio.Unsafe +import zio.ZIO +import zio.json._ import java.time.Instant import java.util.UUID @@ -22,22 +35,24 @@ import org.knora.webapi.messages.util.rdf.JsonLDDocument import org.knora.webapi.messages.util.rdf.JsonLDUtil import org.knora.webapi.messages.v2.responder.resourcemessages._ import org.knora.webapi.messages.v2.responder.searchmessages.SearchResourcesByProjectAndClassRequestV2 -import org.knora.webapi.messages.v2.responder.valuemessages.ArchiveFileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.AudioFileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.DocumentFileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.FileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.MovingImageFileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.StillImageFileValueContentV2 -import org.knora.webapi.messages.v2.responder.valuemessages.TextFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages._ import org.knora.webapi.routing.Authenticator import org.knora.webapi.routing.KnoraRoute import org.knora.webapi.routing.KnoraRouteData import org.knora.webapi.routing.RouteUtilV2 +import org.knora.webapi.routing.RouteUtilV2.getRequiredProjectFromHeader +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 /** * Provides a routing function for API v2 routes that deal with resources. */ -class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) with Authenticator { +class ResourcesRouteV2(routeData: KnoraRouteData, implicit val runtime: zio.Runtime[RestResourceInfoService]) + extends KnoraRoute(routeData) + with Authenticator { val resourcesBasePath: PathMatcher[Unit] = PathMatcher("v2" / "resources") @@ -63,6 +78,7 @@ class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) getResourceHistory() ~ getResourceHistoryEvents() ~ getProjectResourceAndValueHistory() ~ + getResourcesInfo ~ getResources() ~ getResourcesPreview() ~ getResourcesTei() ~ @@ -338,6 +354,44 @@ class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) } } + 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 getRequiredStringQueryParam(requestContext: RequestContext, key: String): String = + getStringQueryParam(requestContext, key).getOrElse( + throw BadRequestException(s"This route requires the parameter '$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) => log.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 projectIri = getRequiredProjectFromHeader(ctx).toIri + val resourceClassIri = getRequiredStringQueryParam(ctx, "resourceClass") + val orderBy = getStringQueryParam(ctx, "orderBy") match { + case None => lastModificationDate + case Some(s) => OrderBy.make(s).getOrElse(throw BadRequestException(s"Invalid value '$s', for orderBy")) + } + val order: Order = getStringQueryParam(ctx, "order") match { + case None => ASC + case Some(s) => Order.make(s).getOrElse(throw BadRequestException(s"Invalid value '$s', for order")) + } + val action = RestResourceInfoService.findByProjectAndResourceClass(projectIri, resourceClassIri, (orderBy, order)) + ctx.complete(unsafeRunZioAndMapJsonResponse(action)) + } + } private def getResources(): Route = path(resourcesBasePath / Segments) { resIris: Seq[String] => get { requestContext => if (resIris.size > routeData.appConfig.v2.resourcesSequence.resultsPerPage) 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/ResourceInfoDto.scala new file mode 100644 index 0000000000..4ca4c628c8 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoDto.scala @@ -0,0 +1,46 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zio.json._ + +import java.time.Instant + +import org.knora.webapi.IRI +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfo + +final case class ListResponseDto private (resources: List[ResourceInfoDto], count: Int) +object ListResponseDto { + val empty: ListResponseDto = ListResponseDto(List.empty, 0) + def apply(list: List[ResourceInfoDto]): ListResponseDto = list match { + case Nil => ListResponseDto.empty + case list => ListResponseDto(list, list.size) + } + + implicit val encoder: JsonEncoder[ListResponseDto] = + DeriveJsonEncoder.gen[ListResponseDto] +} + +final case class ResourceInfoDto private ( + resourceIri: IRI, + creationDate: Instant, + lastModificationDate: Instant, + deleteDate: Option[Instant], + isDeleted: Boolean +) +object ResourceInfoDto { + def apply(info: ResourceInfo): ResourceInfoDto = + ResourceInfoDto( + info.iri, + info.creationDate, + info.lastModificationDate.getOrElse(info.creationDate), + info.deleteDate, + info.isDeleted + ) + + implicit val encoder: JsonEncoder[ResourceInfoDto] = + DeriveJsonEncoder.gen[ResourceInfoDto] +} 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 new file mode 100644 index 0000000000..e996cc4a6a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala @@ -0,0 +1,86 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http.HttpError.BadRequest +import zhttp.http._ +import zio.ZLayer +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 -> !! / "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: Map[String, List[String]]) = { + val order: Validation[BadRequest, Order] = params.get("order") 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: Map[String, List[String]]) = { + val orderBy: Validation[BadRequest, OrderBy] = params.get("orderBy") 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: Map[String, List[String]]) = { + val resourceClassIri: Validation[BadRequest, IRI] = params.get("resourceClass") 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.header(RouteUtilV2.PROJECT_HEADER) match { + case None => Validation.fail(BadRequest(s"Header ${RouteUtilV2.PROJECT_HEADER} may not be empty")) + case Some((_, value)) => Validation.succeed(value.toString) + } + 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/RestResourceInfoService.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala new file mode 100644 index 0000000000..13a892d8a4 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala @@ -0,0 +1,47 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http.HttpError +import zio.IO +import zio.ZIO +import zio.ZLayer + +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 new file mode 100644 index 0000000000..9c32d5e8fa --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceLive.scala @@ -0,0 +1,86 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http.HttpError +import zio.IO + +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/domain/InternalIri.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/InternalIri.scala new file mode 100644 index 0000000000..3fe2159adf --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/InternalIri.scala @@ -0,0 +1,10 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.domain + +import org.knora.webapi.IRI + +final case class InternalIri(value: IRI) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala new file mode 100644 index 0000000000..80255f251a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala @@ -0,0 +1,30 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.domain + +import zio.Task +import zio.ZIO +import zio.ZLayer +import zio.macros.accessible + +import org.knora.webapi.IRI +import org.knora.webapi.messages.StringFormatter + +@accessible +trait IriConverter { + def asInternalIri(iri: IRI): Task[InternalIri] +} + +final case class IriConverterLive(stringFormatter: StringFormatter) extends IriConverter { + def asInternalIri(iri: IRI): Task[InternalIri] = + ZIO.attempt { + stringFormatter.toSmartIri(iri).internalIri + }.map(InternalIri) +} + +object IriConverter { + val layer: ZLayer[StringFormatter, Nothing, IriConverterLive] = ZLayer.fromFunction(IriConverterLive(_)) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfo.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfo.scala new file mode 100644 index 0000000000..bef7c454d1 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfo.scala @@ -0,0 +1,24 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.domain + +import java.time.Instant + +import org.knora.webapi.IRI + +case class ResourceInfo( + iri: IRI, + creationDate: Instant, + lastModificationDate: Option[Instant], + deleteDate: Option[Instant], + isDeleted: Boolean +) +object ResourceInfo { + def apply(iri: IRI, creationDate: Instant, lastModificationDate: Option[Instant]): ResourceInfo = + ResourceInfo(iri, creationDate, lastModificationDate, deleteDate = None, isDeleted = false) + def apply(iri: IRI, creationDate: Instant, lastModificationDate: Option[Instant], deleteDate: Instant): ResourceInfo = + ResourceInfo(iri, creationDate, lastModificationDate, Some(deleteDate), isDeleted = true) +} 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 new file mode 100644 index 0000000000..280f055a1c --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala @@ -0,0 +1,26 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.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 + +@accessible +trait ResourceInfoRepo { + def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): Task[List[ResourceInfo]] +} + +object ResourceInfoRepo { + val layer: ZLayer[TriplestoreService, Nothing, ResourceInfoRepo] = + ZLayer.fromFunction(ResourceInfoRepoLive(_)) +} 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 new file mode 100644 index 0000000000..87d863a015 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoLive.scala @@ -0,0 +1,44 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.repo + +import zio.UIO +import zio.ZIO + +import java.time.Instant + +import org.knora.webapi.messages.twirl.queries.sparql.v2.txt.resourcesByCreationDate +import org.knora.webapi.messages.util.rdf.SparqlSelectResult +import org.knora.webapi.messages.util.rdf.VariableResultsRow +import org.knora.webapi.slice.resourceinfo.domain.InternalIri +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfo +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo +import org.knora.webapi.store.triplestore.api.TriplestoreService + +final case class ResourceInfoRepoLive(ts: TriplestoreService) extends ResourceInfoRepo { + + override def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): UIO[List[ResourceInfo]] = { + val query = resourcesByCreationDate(resourceClass, projectIri).toString + ZIO.debug(query) *> ts.sparqlHttpSelect(query).map(toResourceInfoList) + } + + private def toResourceInfoList(result: SparqlSelectResult): List[ResourceInfo] = + result.results.bindings.map(toResourceInfo).toList + + private def toResourceInfo(row: VariableResultsRow): ResourceInfo = { + val rowMap = row.rowMap + ResourceInfo( + rowMap("resource"), + Instant.parse(rowMap("creationDate")), + rowMap.get("lastModificationDate").map(Instant.parse(_)), + rowMap.get("deleteDate").map(Instant.parse), + rowMap("isDeleted").toBoolean + ) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceHttpConnectorImpl.scala b/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceHttpConnectorImpl.scala index ad1206e138..86079fe8ee 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceHttpConnectorImpl.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceHttpConnectorImpl.scala @@ -454,7 +454,7 @@ case class TriplestoreServiceHttpConnectorImpl( // to the parent folder where the files can be found val inputFile = Paths.get("..", elem.path) if (!Files.exists(inputFile)) { - throw BadRequestException(s"File ${inputFile.toAbsolutePath} does not exist") + throw BadRequestException(s"File ${inputFile} does not exist") } val fileEntity = new FileEntity(inputFile.toFile, ContentType.create(mimeTypeTextTurtle, "UTF-8")) 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 new file mode 100644 index 0000000000..e034707d9d --- /dev/null +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/resourcesByCreationDate.scala.txt @@ -0,0 +1,22 @@ +@* + * Copyright © 2021 - 2022 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 new file mode 100644 index 0000000000..2d1cb98046 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoServiceSpec.scala @@ -0,0 +1,133 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http.HttpError.BadRequest +import zio.test.Assertion.equalTo +import zio.test.Assertion.fails +import zio.test._ + +import java.time.Instant.now +import java.time.temporal.ChronoUnit.DAYS +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.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 + +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 + .findByProjectAndResourceClass( + knownProjectIRI.value, + "invalid-resource-class", + (lastModificationDate, ASC) + ) + .exit + } yield assert(result)( + fails(equalTo(BadRequest("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", + "http://unknown-resource-class", + (lastModificationDate, ASC) + ) + } yield assertTrue(actual == ListResponseDto.empty) + }, + test("should return empty list if no resources found // unknown resourceClass") { + for { + actual <- RestResourceInfoService.findByProjectAndResourceClass( + knownProjectIRI.value, + "http://unknown-resource-class", + (lastModificationDate, ASC) + ) + } yield assertTrue(actual == ListResponseDto.empty) + }, + test("should return empty list if no resources found // unknown project") { + for { + actual <- + RestResourceInfoService.findByProjectAndResourceClass( + "http://unknown-project", + knownResourceClass.value, + (lastModificationDate, ASC) + ) + } yield assertTrue(actual == ListResponseDto.empty) + }, + test( + """given two ResourceInfo exist + | when findByProjectAndResourceClass + | then it should return all info sorted by (lastModificationDate, ASC) + |""".stripMargin.linesIterator.mkString("") + ) { + val given1 = ResourceInfo("http://resourceIri/" + randomUUID, now.minus(10, DAYS), Some(now.minus(9, DAYS))) + val given2 = + ResourceInfo("http://resourceIri/" + randomUUID, now.minus(20, DAYS), Some(now.minus(8, DAYS)), now) + for { + _ <- ResourceInfoRepoFake.addAll(List(given1, given2), knownProjectIRI, knownResourceClass) + actual <- + RestResourceInfoService.findByProjectAndResourceClass( + knownProjectIRI.value, + knownResourceClass.value, + (lastModificationDate, ASC) + ) + } yield { + val items = List(given1, given2).map(ResourceInfoDto(_)).sortBy(_.lastModificationDate) + assertTrue(actual == ListResponseDto(items)) + } + }, + test( + """given two ResourceInfo exist + | when findByProjectAndResourceClass ordered by (creationDate, DESC) + | then it should return all info sorted correctly + |""".stripMargin.linesIterator.mkString("") + ) { + val given1 = ResourceInfo("http://resourceIri/" + randomUUID, now.minus(10, DAYS), Some(now.minus(9, DAYS))) + val given2 = + ResourceInfo("http://resourceIri/" + randomUUID, now.minus(20, DAYS), Some(now.minus(8, DAYS)), now) + for { + _ <- ResourceInfoRepoFake.addAll(List(given1, given2), knownProjectIRI, knownResourceClass) + actual <- RestResourceInfoService.findByProjectAndResourceClass( + knownProjectIRI.value, + knownResourceClass.value, + ordering = (creationDate, DESC) + ) + } yield { + val items = List(given1, given2).map(ResourceInfoDto(_)).sortBy(_.creationDate).reverse + assertTrue(actual == ListResponseDto(items)) + } + } + ).provide( + IriConverter.layer, + StringFormatter.test, + RestResourceInfoService.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 new file mode 100644 index 0000000000..c0e1cb4c28 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRouteSpec.scala @@ -0,0 +1,114 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http._ +import zio.ZIO +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(!! / "v2" / "resources" / "info") + private val projectHeader = Headers("x-knora-accept-project", testProjectIri) + + private def sendRequest(req: Request) = + for { + route <- ZIO.service[ResourceInfoRoute].map(_.route) + response <- route(req) + } yield response + + def spec = + suite("ResourceInfoRoute /v2/resources/info")( + test("given no required params/headers were passed should respond with BadRequest") { + val request = Request(url = baseUrl) + for { + response <- sendRequest(request) + } yield assertTrue(response.status == Status.BadRequest) + }, + test("given more than one resource class should respond with BadRequest") { + val url = baseUrl.setQueryParams(Map("resourceClass" -> List(testResourceClass, "http://anotherResourceClass"))) + val request = Request(url = url, headers = projectHeader) + for { + response <- sendRequest(request) + } yield assertTrue(response.status == Status.BadRequest) + }, + test("given no projectIri should respond with BadRequest") { + val url = baseUrl.setQueryParams(Map("resourceClass" -> List(testResourceClass))) + val request = Request(url = url) + for { + response <- sendRequest(request) + } yield assertTrue(response.status == Status.BadRequest) + }, + test("given all mandatory parameters should respond with OK") { + val url = baseUrl.setQueryParams(Map("resourceClass" -> List(testResourceClass))) + val request = Request(url = url, 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.setQueryParams(Map("resourceClass" -> List(testResourceClass))) + val request = Request(url = url, headers = 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.setQueryParams( + Map( + "resourceClass" -> List(testResourceClass), + "orderBy" -> List("creationDate"), + "order" -> List("DESC") + ) + ) + val request = Request(url = url, headers = 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 new file mode 100644 index 0000000000..b9de0ec727 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoServiceSpy.scala @@ -0,0 +1,48 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import zhttp.http.HttpError +import zio._ + +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.service[RestResourceInfoServiceSpy].flatMap(_.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 new file mode 100644 index 0000000000..5c82413121 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/ResourceInfoRepoFake.scala @@ -0,0 +1,72 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.repo + +import zio.Ref +import zio.Task +import zio.UIO +import zio.ULayer +import zio.URIO +import zio.ZIO +import zio.ZLayer + +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]]]) + extends ResourceInfoRepo { + + override def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): Task[List[ResourceInfo]] = + entitiesRef.get.map(_.getOrElse((projectIri, resourceClass), List.empty)) + + def add(entity: ResourceInfo, projectIRI: InternalIri, 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] = + entities.map(add(_, projectIri, resourceClass)).reduce(_ *> _) + + def removeAll(): UIO[Unit] = + entitiesRef.set(Map.empty[(InternalIri, InternalIri), List[ResourceInfo]]) +} + +object ResourceInfoRepoFake { + + val knownProjectIRI = domain.InternalIri("http://some-project-iri") + val knownResourceClass = domain.InternalIri("http://some-resource-class") + + def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): ZIO[ResourceInfoRepoFake, Throwable, List[ResourceInfo]] = + ZIO.service[ResourceInfoRepoFake].flatMap(_.findByProjectAndResourceClass(projectIri, resourceClass)) + + def addAll( + items: List[ResourceInfo], + projectIri: InternalIri, + resourceClass: InternalIri + ): URIO[ResourceInfoRepoFake, Unit] = + ZIO.service[ResourceInfoRepoFake].flatMap(_.addAll(items, projectIri, resourceClass)) + + def add( + entity: ResourceInfo, + projectIri: InternalIri, + resourceClass: InternalIri + ): URIO[ResourceInfoRepoFake, Unit] = + ZIO.service[ResourceInfoRepoFake].flatMap(_.add(entity, projectIri, resourceClass)) + + def removeAll(): URIO[ResourceInfoRepoFake, Unit] = + ZIO.service[ResourceInfoRepoFake].flatMap(_.removeAll()) + + val layer: ULayer[ResourceInfoRepoFake] = + ZLayer.fromZIO(Ref.make(Map.empty[(InternalIri, InternalIri), List[ResourceInfo]]).map(ResourceInfoRepoFake(_))) +} diff --git a/webapi/src/test/scala/org/knora/webapi/store/triplestore/upgrade/plugins/ApplyUpgradePluginToTestData.scala b/webapi/src/test/scala/org/knora/webapi/store/triplestore/upgrade/plugins/ApplyUpgradePluginToTestData.scala new file mode 100644 index 0000000000..6c31f3081c --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/store/triplestore/upgrade/plugins/ApplyUpgradePluginToTestData.scala @@ -0,0 +1,78 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.store.triplestore.upgrade.plugins + +import org.apache.jena.riot.RDFDataMgr +import org.apache.jena.riot.RDFFormat +import zio.Scope +import zio.ZIO +import zio.ZIOAppDefault + +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Path +import scala.jdk.CollectionConverters.IteratorHasAsScala + +import org.knora.webapi.messages.util.rdf.RdfFeatureFactory +import org.knora.webapi.messages.util.rdf.RdfModel +import org.knora.webapi.messages.util.rdf.Turtle +import org.knora.webapi.messages.util.rdf.jenaimpl.JenaConversions._ +import org.knora.webapi.store.triplestore.upgrade.UpgradePlugin + +/** + * This class may be run if you wish to apply your upgrade plugin on our test data. + * For this you must first provide an instance of your plugin as `val upgradePlugin` + * and also specify the absolut path to the "test_data" folder in `val testDataPath`. + */ +object ApplyUpgradePluginToTestData extends ZIOAppDefault { + + val upgradePlugin = new NoopPlugin() + val testDataPath = "/test_data/all_data" + + def discoverFiles(dirPath: String) = + Files.list(Path.of(dirPath)).filter(Files.isRegularFile(_)).iterator.asScala.toArray + + def applyUpgradePluginTo(plugin: UpgradePlugin, path: Path): ZIO[Any with Scope, Throwable, Unit] = for { + model <- parseRdfModelFromFile(path) + transformedModel <- applyPluginToModel(plugin, model) + _ <- writeModelToFile(transformedModel, path) + } yield () + + def parseRdfModelFromFile(path: Path) = + for { + fis <- fileInputStreamFor(path) + model <- parseRdfModel(fis) + } yield model + + def applyPluginToModel(plugin: UpgradePlugin, model: RdfModel) = ZIO.succeed { + plugin.transform(model); + model + } + + def writeModelToFile(rdfModel: RdfModel, path: Path) = { + val defaultGraph = rdfModel.asJenaDataset.asDatasetGraph.getDefaultGraph + for { + fos <- fileOutputStreamFor(path) + _ <- ZIO.attempt(RDFDataMgr.write(fos, defaultGraph, RDFFormat.TURTLE_PRETTY)) + } yield () + } + + def fileInputStreamFor(path: Path) = + ZIO.acquireRelease(ZIO.attemptBlockingIO(new FileInputStream(path.toFile)))(fis => ZIO.succeedBlocking(fis.close())) + + def parseRdfModel(is: InputStream) = + ZIO.succeed(RdfFeatureFactory.getRdfFormatUtil().inputStreamToRdfModel(is, Turtle)) + + def fileOutputStreamFor(path: Path) = + ZIO.acquireRelease(ZIO.attemptBlockingIO(new FileOutputStream(path.toFile)))(fos => ZIO.succeedBlocking(fos)) + + def run = + ZIO.foreach(discoverFiles(testDataPath))(pathToFile => + ZIO.debug(s"applying to $pathToFile") *> applyUpgradePluginTo(upgradePlugin, pathToFile) + ) +}