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 1cef56af8df..13f6bb85aa3 100644 --- a/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala +++ b/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala @@ -1,20 +1,22 @@ package org.knora.webapi.core -import zio.ZLayer import org.knora.webapi.auth.JWTService 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.LiveRestResourceInfoService +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.repo.LiveResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceManager import org.knora.webapi.store.cache.impl.CacheServiceInMemImpl import org.knora.webapi.store.iiif.IIIFServiceManager -import org.knora.webapi.store.iiif.impl.IIIFServiceMockImpl -import org.knora.webapi.store.iiif.impl.IIIFServiceSipiImpl +import org.knora.webapi.store.iiif.impl.{IIIFServiceMockImpl, IIIFServiceSipiImpl} import org.knora.webapi.store.triplestore.TriplestoreServiceManager import org.knora.webapi.store.triplestore.impl.TriplestoreServiceHttpConnectorImpl import org.knora.webapi.store.triplestore.upgrade.RepositoryUpdater -import org.knora.webapi.testcontainers.FusekiTestContainer -import org.knora.webapi.testcontainers.SipiTestContainer +import org.knora.webapi.testcontainers.{FusekiTestContainer, SipiTestContainer} import org.knora.webapi.testservices.TestClientService +import zio.ZLayer object LayersTest { @@ -43,6 +45,10 @@ object LayersTest { State.layer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.layer, + LiveRestResourceInfoService.layer, + LiveResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer, // testcontainers SipiTestContainer.layer, FusekiTestContainer.layer, @@ -69,6 +75,10 @@ object LayersTest { State.layer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.layer, + LiveRestResourceInfoService.layer, + LiveResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer, // testcontainers FusekiTestContainer.layer, // Test services @@ -94,6 +104,10 @@ object LayersTest { State.layer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.layer, + LiveRestResourceInfoService.layer, + LiveResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer, // testcontainers FusekiTestContainer.layer, // Test services @@ -119,6 +133,10 @@ object LayersTest { State.layer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.layer, + LiveRestResourceInfoService.layer, + LiveResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer, // testcontainers FusekiTestContainer.layer, // Test services @@ -144,6 +162,10 @@ object LayersTest { State.layer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.layer, + LiveRestResourceInfoService.layer, + LiveResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer, // testcontainers FusekiTestContainer.layer, // Test services 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 59613f3c2ba..0f75873c812 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 57f71bade1e..5add431191a 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 818a1c79e26..d8cbb70df0a 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 21b321b41bf..17e340cbb57 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/LiveIriConverterSpec.scala b/webapi/src/it/scala/org/knora/webapi/slice/resourceinfo/api/LiveIriConverterSpec.scala new file mode 100644 index 00000000000..63be5ef4f19 --- /dev/null +++ b/webapi/src/it/scala/org/knora/webapi/slice/resourceinfo/api/LiveIriConverterSpec.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 LiveIriConverterSpec extends ZIOSpecDefault { + + def spec = suite("IriConverter")( + 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.testLayer) +} 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 5e5540d9031..20043bdb951 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 @@ -10,7 +10,9 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike 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 +39,15 @@ 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)).get + /** * 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 b2e5b8902c0..f0c8f835a33 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/HttpServerWithZIOHttp.scala b/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala index 022b11d6ef7..fa6138c6825 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/HttpServerWithZIOHttp.scala @@ -5,25 +5,23 @@ package org.knora.webapi.core -import zhttp.service.Server -import zio.ZLayer -import zio._ - import org.knora.webapi.config.AppConfig import org.knora.webapi.routing.IndexApp +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute +import zhttp.service.Server +import zio.{ZIO, ZLayer} object HttpServerWithZIOHttp { - val routes = IndexApp() - - val layer: ZLayer[AppConfig & State, Nothing, Unit] = + val layer: ZLayer[ResourceInfoRoute with AppConfig, Nothing, Unit] = ZLayer { for { appConfig <- ZIO.service[AppConfig] + riRoute <- ZIO.service[ResourceInfoRoute].map(_.route) port = appConfig.knoraApi.externalZioPort + routes = IndexApp() ++ riRoute _ <- Server.start(port, routes).forkDaemon - _ <- ZIO.logInfo(">>> Acquire ZIO HTTP Server <<<") + _ <- ZIO.logInfo(s">>> Acquire ZIO HTTP Server on port $port<<<") } 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 b8ca62820e6..3f6113ea3f1 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 d45fb48dc04..d13132b3b83 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -10,7 +10,12 @@ 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.slice.resourceinfo.api.LiveRestResourceInfoService +import org.knora.webapi.slice.resourceinfo.api.ResourceInfoRoute +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.repo.LiveResourceInfoRepo import org.knora.webapi.store.cache.CacheServiceManager import org.knora.webapi.store.cache.api.CacheService import org.knora.webapi.store.cache.impl.CacheServiceInMemImpl @@ -58,9 +63,14 @@ object LayersLive { HttpServerWithZIOHttp.layer, // this is the new ZIO HTTP server layer IIIFServiceManager.layer, IIIFServiceSipiImpl.layer, + IriConverter.layer, JWTService.layer, + LiveResourceInfoRepo.layer, + LiveRestResourceInfoService.layer, RepositoryUpdater.layer, + ResourceInfoRoute.layer, State.layer, + StringFormatter.liveLayer, TriplestoreServiceManager.layer, TriplestoreServiceHttpConnectorImpl.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 0b66f8ff6fe..e1388400156 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 ecfcaba4050..ec53e0efb7b 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 08ed3e76048..f68d9743a63 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 liveLayer: ZLayer[AppConfig, Nothing, StringFormatter] = ZLayer.fromFunction { appConfig: AppConfig => + StringFormatter.init(appConfig) + StringFormatter.getGeneralInstance + } + + val testLayer: 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 9232b704ae1..3c1a6578a0c 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -17,36 +17,10 @@ import org.knora.webapi.core.ActorSystem import org.knora.webapi.core.AppRouter 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 +31,7 @@ object ApiRoutes { /** * All routes composed together. */ - val layer: ZLayer[ActorSystem & AppRouter & core.State & AppConfig, Nothing, ApiRoutes] = + val layer: ZLayer[ActorSystem & AppRouter & core.State & AppConfig with RestResourceInfoService, Nothing, ApiRoutes] = ZLayer { for { sys <- ZIO.service[ActorSystem] @@ -70,7 +44,7 @@ object ApiRoutes { appConfig = appConfig ) ) - runtime <- ZIO.runtime[core.State] + runtime <- ZIO.runtime[core.State with RestResourceInfoService] } yield ApiRoutesImpl(routeData, runtime, appConfig) } } @@ -82,8 +56,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 +85,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 index 06b54cbc52e..66e860d10a9 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/IndexApp.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/IndexApp.scala @@ -1,8 +1,15 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + package org.knora.webapi.routing import zhttp.html._ import zhttp.http._ +import org.knora.webapi.http.version.BuildInfo + /** * Provides the '/' endpoint serving a small index page. */ @@ -12,11 +19,11 @@ object IndexApp { Http.collect[Request] { case Method.GET -> !! => Response.html(Html.fromString(indexPage)) } private val indexPage = - """ - |DSP-API public routes - | - |

nothing yet

- |""".stripMargin + s""" + |DSP-API public routes + | + |

version: ${BuildInfo.version}

+ |""".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 89ed8990d27..796db2f08f3 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV2.scala @@ -15,6 +15,9 @@ import com.typesafe.scalalogging.Logger import scala.concurrent.ExecutionContext import scala.concurrent.Future +import scala.util.Failure +import scala.util.Success +import scala.util.Try import scala.util.control.Exception.catching import dsp.errors.BadRequestException @@ -198,6 +201,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 + */ + def getRequiredProjectFromHeader(ctx: RequestContext)(implicit stringFormatter: StringFormatter): Try[SmartIri] = + getProject(ctx) match { + case None => Failure(BadRequestException(s"This route requires the request header ${RouteUtilV2.PROJECT_HEADER}")) + case Some(value) => Success(value) + } + /** * 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 deadbfe32da..bc11d739fc5 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.LiveRestResourceInfoService.ASC +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.Order +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.OrderBy +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.lastModificationDate +import org.knora.webapi.slice.resourceinfo.api.RestResourceInfoService /** * 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,52 @@ 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 = + getQueryParamsMap(requestContext).getOrElse( + key, + throw BadRequestException(s"This route requires the parameter '$key'") + ) + + private def getRequiredResourceClassFromQueryParams(ctx: RequestContext): SmartIri = { + val resourceClass = getRequiredStringQueryParam(ctx, "resourceClass") + resourceClass + .toSmartIriWithErr(throw BadRequestException(s"Invalid resource class IRI: $resourceClass")) + } + + 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).get.internalIri + val resourceClassIri = getRequiredResourceClassFromQueryParams(ctx).internalIri + 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/LiveRestResourceInfoService.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoService.scala new file mode 100644 index 00000000000..c6741d0ed40 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/LiveRestResourceInfoService.scala @@ -0,0 +1,88 @@ +/* + * 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.ZLayer + +import java.time.Instant + +import org.knora.webapi.IRI +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService._ +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfoRepo + +final case class LiveRestResourceInfoService(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 LiveRestResourceInfoService { + + 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 + } + } + val layer = ZLayer.fromFunction(new LiveRestResourceInfoService(_, _)) +} 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 00000000000..4ca4c628c85 --- /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 00000000000..038928b0843 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/ResourceInfoRoute.scala @@ -0,0 +1,82 @@ +/* + * 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 org.knora.webapi.IRI +import org.knora.webapi.routing.RouteUtilV2 +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.{ASC, Order, OrderBy, lastModificationDate} +import zhttp.http.HttpError.BadRequest +import zhttp.http._ +import zio.ZLayer +import zio.json.EncoderOps +import zio.prelude.Validation + +final case class ResourceInfoRoute(restService: RestResourceInfoService) { + + val route: Http[Any, Nothing, Request, Response] = + 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.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 00000000000..0e2a345c784 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/api/RestResourceInfoService.scala @@ -0,0 +1,34 @@ +/* + * Copyright © 2021 - 2022 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.resourceinfo.api + +import org.knora.webapi.IRI +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.{Order, OrderBy} +import zhttp.http.HttpError +import zio.IO +import zio.macros.accessible + +@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 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] +} 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 00000000000..3fe2159adfb --- /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 00000000000..c33300ee9ca --- /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 LiveIriConverter(stringFormatter: StringFormatter) extends IriConverter { + def asInternalIri(iri: IRI): Task[InternalIri] = + ZIO.attempt { + stringFormatter.toSmartIri(iri).internalIri + }.map(InternalIri) +} + +object IriConverter { + val layer = ZLayer.fromFunction(LiveIriConverter(_)) +} 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 00000000000..bef7c454d17 --- /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 00000000000..e88bc3b4c27 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/ResourceInfoRepo.scala @@ -0,0 +1,17 @@ +/* + * 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.macros.accessible + +@accessible +trait ResourceInfoRepo { + def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): Task[List[ResourceInfo]] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/LiveResourceInfoRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/LiveResourceInfoRepo.scala new file mode 100644 index 00000000000..05427ca17d8 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/repo/LiveResourceInfoRepo.scala @@ -0,0 +1,50 @@ +/* + * 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 zio.ZLayer + +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 LiveResourceInfoRepo(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 + ) + } +} + +object LiveResourceInfoRepo { + val layer: ZLayer[TriplestoreService, Nothing, LiveResourceInfoRepo] = + ZLayer.fromZIO(ZIO.service[TriplestoreService].map(LiveResourceInfoRepo(_))) +} 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 ad1206e138e..86079fe8ee2 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 00000000000..e034707d9dd --- /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 00000000000..43b50e5755f --- /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.LiveRestResourceInfoService.ASC +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.DESC +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.creationDate +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.lastModificationDate +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.domain.ResourceInfo +import org.knora.webapi.slice.resourceinfo.repo.TestResourceInfoRepo +import org.knora.webapi.slice.resourceinfo.repo.TestResourceInfoRepo.knownProjectIRI +import org.knora.webapi.slice.resourceinfo.repo.TestResourceInfoRepo.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 { + _ <- TestResourceInfoRepo.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 { + _ <- TestResourceInfoRepo.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.testLayer, + LiveRestResourceInfoService.layer, + TestResourceInfoRepo.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 00000000000..1959409f3f6 --- /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.LiveRestResourceInfoService.ASC +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.DESC +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.creationDate +import org.knora.webapi.slice.resourceinfo.api.LiveRestResourceInfoService.lastModificationDate +import org.knora.webapi.slice.resourceinfo.api.SpyLiveRestResourceInfoService.orderingKey +import org.knora.webapi.slice.resourceinfo.api.SpyLiveRestResourceInfoService.projectIriKey +import org.knora.webapi.slice.resourceinfo.api.SpyLiveRestResourceInfoService.resourceClassKey +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.repo.TestResourceInfoRepo + +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) *> SpyLiveRestResourceInfoService.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 <- SpyLiveRestResourceInfoService.lastInvocation + } yield assertTrue( + lastInvocation == + Map( + projectIriKey -> expectedProjectIri, + resourceClassKey -> expectedResourceClassIri, + orderingKey -> (creationDate, DESC) + ) + ) + } + ).provide( + ResourceInfoRoute.layer, + SpyLiveRestResourceInfoService.layer, + TestResourceInfoRepo.layer, + IriConverter.layer, + StringFormatter.testLayer + ) +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/SpyLiveRestResourceInfoService.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/SpyLiveRestResourceInfoService.scala new file mode 100644 index 00000000000..a01db841ff5 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/api/SpyLiveRestResourceInfoService.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.SpyLiveRestResourceInfoService.orderingKey +import org.knora.webapi.slice.resourceinfo.api.SpyLiveRestResourceInfoService.projectIriKey +import org.knora.webapi.slice.resourceinfo.api.SpyLiveRestResourceInfoService.resourceClassKey +import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resourceinfo.repo.TestResourceInfoRepo + +case class SpyLiveRestResourceInfoService( + lastInvocation: Ref[Map[String, Any]], + realService: LiveRestResourceInfoService +) extends RestResourceInfoService { + override def findByProjectAndResourceClass( + projectIri: IRI, + resourceClass: IRI, + ordering: (LiveRestResourceInfoService.OrderBy, LiveRestResourceInfoService.Order) + ): IO[HttpError, ListResponseDto] = for { + _ <- + lastInvocation.set(Map(projectIriKey -> projectIri, resourceClassKey -> resourceClass, orderingKey -> ordering)) + result <- realService.findByProjectAndResourceClass(projectIri, resourceClass, ordering) + } yield result +} + +object SpyLiveRestResourceInfoService { + val projectIriKey = "projectIri" + val resourceClassKey = "resourceClass" + val orderingKey = "ordering" + def lastInvocation: ZIO[SpyLiveRestResourceInfoService, Nothing, Map[String, Any]] = + ZIO.service[SpyLiveRestResourceInfoService].flatMap(_.lastInvocation.get) + + val layer: ZLayer[IriConverter with TestResourceInfoRepo, Nothing, SpyLiveRestResourceInfoService] = ZLayer.fromZIO { + for { + ref <- Ref.make(Map.empty[String, Any]) + repo <- ZIO.service[TestResourceInfoRepo] + iriConverter <- ZIO.service[IriConverter] + realService <- ZIO.succeed(LiveRestResourceInfoService(repo, iriConverter)) + } yield SpyLiveRestResourceInfoService(ref, realService) + } +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/TestResourceInfoRepo.scala b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/TestResourceInfoRepo.scala new file mode 100644 index 00000000000..07bf771d6ee --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/resourceinfo/repo/TestResourceInfoRepo.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.UIO +import zio.ULayer +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 TestResourceInfoRepo(entitiesRef: Ref[Map[(InternalIri, InternalIri), List[ResourceInfo]]]) + extends ResourceInfoRepo { + + override def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): UIO[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 TestResourceInfoRepo { + + val knownProjectIRI = domain.InternalIri("http://some-project-iri") + val knownResourceClass = domain.InternalIri("http://some-resource-class") + + def findByProjectAndResourceClass( + projectIri: InternalIri, + resourceClass: InternalIri + ): ZIO[ResourceInfoRepo, Throwable, List[ResourceInfo]] = { + ZIO.debug(s"query for $projectIri, $resourceClass") + ResourceInfoRepo.findByProjectAndResourceClass(projectIri, resourceClass) + } + + def addAll( + items: List[ResourceInfo], + projectIri: InternalIri, + resourceClass: InternalIri + ): ZIO[TestResourceInfoRepo, Nothing, Unit] = + ZIO.service[TestResourceInfoRepo].flatMap(_.addAll(items, projectIri, resourceClass)) + + def add( + entity: ResourceInfo, + projectIri: InternalIri, + resourceClass: InternalIri + ): ZIO[TestResourceInfoRepo, Nothing, Unit] = + ZIO.service[TestResourceInfoRepo].flatMap(_.add(entity, projectIri, resourceClass)) + + def removeAll(): ZIO[TestResourceInfoRepo, Nothing, Unit] = + ZIO.service[TestResourceInfoRepo].flatMap(_.removeAll()) + + val layer: ULayer[TestResourceInfoRepo] = + ZLayer.fromZIO(Ref.make(Map.empty[(InternalIri, InternalIri), List[ResourceInfo]]).map(TestResourceInfoRepo(_))) +} 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 00000000000..0acce3eda78 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/store/triplestore/upgrade/plugins/ApplyUpgradePluginToTestData.scala @@ -0,0 +1,73 @@ +/* + * 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 + +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) + ) +}