diff --git a/docs/05-internals/design/adr/ADR-0009-api-version-3.md b/docs/05-internals/design/adr/ADR-0009-api-version-3.md new file mode 100644 index 00000000000..6b49f1151e4 --- /dev/null +++ b/docs/05-internals/design/adr/ADR-0009-api-version-3.md @@ -0,0 +1,23 @@ +# ADR-0001 Record architectural decisions as ADR + +Date: 2025-08-12 + +## Status + +Proposal + +## Related + +- [RFC-018](https://www.notion.so/dasch-swiss/draft-RFC-018-PoC-for-a-FE-friendly-v3-route-get-project-information-2408946b7d40800b9e30dbee39202627) + +## Context + +... + +## Decision + +... + +## Consequences + +... diff --git a/project/Dependencies.scala b/project/Dependencies.scala index cbb62c70945..05078de0101 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -33,6 +33,7 @@ object Dependencies { val ZioSchemaVersion = "1.7.4" val ZioMockVersion = "1.0.0-RC12" val ZioVersion = "2.1.20" + val ZioCacheVersion = "0.2.4" // ZIO val zio = "dev.zio" %% "zio" % ZioVersion @@ -41,6 +42,7 @@ object Dependencies { val zioConfigTypesafe = "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion val ZioJsonVersion = "0.7.44" + val zioCache = "dev.zio" %% "zio-cache" % ZioCacheVersion val zioJson = "dev.zio" %% "zio-json" % ZioJsonVersion val zioLogging = "dev.zio" %% "zio-logging" % ZioLoggingVersion val zioLoggingSlf4jBridge = "dev.zio" %% "zio-logging-slf4j2-bridge" % ZioLoggingVersion @@ -214,6 +216,7 @@ object Dependencies { titaniumJSONLD, topbraidShacl, zio, + zioCache, zioConfig, zioConfigMagnolia, zioConfigTypesafe, 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 626cf448f10..befa669d271 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -66,6 +66,9 @@ import org.knora.webapi.slice.shacl.ShaclModule import org.knora.webapi.slice.shacl.api.ShaclApiModule import org.knora.webapi.slice.shacl.api.ShaclApiRoutes import org.knora.webapi.slice.shacl.api.ShaclEndpoints +import org.knora.webapi.slice.v3.V3Module +import org.knora.webapi.slice.v3.V3Routes +import org.knora.webapi.slice.v3.api.ApiV3Endpoints import org.knora.webapi.store.iiif.IIIFRequestMessageHandler import org.knora.webapi.store.iiif.IIIFRequestMessageHandlerLive import org.knora.webapi.store.iiif.api.SipiService @@ -140,6 +143,7 @@ object LayersLive { self => ApiComplexV2JsonLdRequestParser & ApiRoutes & ApiV2Endpoints & + ApiV3Endpoints & AssetPermissionsResponder & AuthenticationApiModule.Provided & AuthorizationRestService & @@ -179,6 +183,7 @@ object LayersLive { self => StandoffResponderV2 & StandoffTagUtilV2 & State & + V3Routes & ValuesResponderV2 // format: on @@ -235,6 +240,7 @@ object LayersLive { self => StandoffTagUtilV2Live.layer, State.layer, TapirToPekkoInterpreter.layer, + V3Module.layer, ValuesResponderV2.layer, // ZLayer.Debug.mermaid, ) 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 90e1f8e858f..fd4704f96bc 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -24,6 +24,7 @@ import org.knora.webapi.slice.resources.api.ResourcesApiRoutes import org.knora.webapi.slice.search.api.SearchApiRoutes import org.knora.webapi.slice.security.api.AuthenticationApiRoutes import org.knora.webapi.slice.shacl.api.ShaclApiRoutes +import org.knora.webapi.slice.v3.V3Routes /** * All routes composed together and CORS activated based on the @@ -42,6 +43,7 @@ final case class ApiRoutes( shaclApiRoutes: ShaclApiRoutes, managementRoutes: ManagementRoutes, ontologiesRoutes: OntologiesApiRoutes, + v3Routes: V3Routes, system: ActorSystem, ) { val routes: Route = @@ -57,7 +59,8 @@ final case class ApiRoutes( resourceInfoRoutes.routes ++ resourcesApiRoutes.routes ++ searchApiRoutes.routes ++ - shaclApiRoutes.routes).reduce(_ ~ _) + shaclApiRoutes.routes ++ + Seq(v3Routes.routes)).reduce(_ ~ _) } } } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/DocsGenerator.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/DocsGenerator.scala index b97c5caad50..5c95dfe73cd 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/DocsGenerator.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/api/DocsGenerator.scala @@ -50,6 +50,7 @@ import org.knora.webapi.slice.security.AuthenticatorError import org.knora.webapi.slice.security.AuthenticatorError.* import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2 import org.knora.webapi.slice.shacl.api.ShaclEndpoints +import org.knora.webapi.slice.v3.projects.api.ProjectsEndpoints as V3ProjectsEndpoints final case class DocsNoopAuthenticator() extends Authenticator { override def calculateCookieName(): String = "KnoraAuthenticationMFYGSLTEMFZWG2BOON3WS43THI2DIMY9" @@ -74,11 +75,13 @@ object DocsGenerator extends ZIOAppDefault { adminEndpoints <- ZIO.serviceWith[AdminApiEndpoints](_.endpoints) managementEndpoints <- ZIO.serviceWith[ManagementEndpoints](_.endpoints) v2Endpoints <- ZIO.serviceWith[ApiV2Endpoints](_.endpoints) + v3ProjectsEndpoints <- ZIO.serviceWith[V3ProjectsEndpoints](_.endpoints) shaclEndpoints <- ZIO.serviceWith[ShaclEndpoints](_.endpoints) path = Path(args.headOption.getOrElse("/tmp")) filesWritten <- writeToFile(adminEndpoints, path, "admin-api") <*> writeToFile(v2Endpoints, path, "v2") <*> + writeToFile(v3ProjectsEndpoints, path, "v3-projects") <*> writeToFile(managementEndpoints, path, "management") <*> writeToFile(shaclEndpoints, path, "shacl") _ <- ZIO.logInfo(s"Wrote $filesWritten") @@ -101,6 +104,7 @@ object DocsGenerator extends ZIOAppDefault { PermissionsEndpoints.layer, ProjectsLegalInfoEndpoints.layer, ProjectsEndpoints.layer, + V3ProjectsEndpoints.layer, ResourceInfoEndpoints.layer, ResourcesEndpoints.layer, SearchEndpoints.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/MetricsServer.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/MetricsServer.scala index ed776684485..d8faab5db26 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/MetricsServer.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/MetricsServer.scala @@ -24,11 +24,11 @@ import org.knora.webapi.slice.admin.api.AdminApiEndpoints import org.knora.webapi.slice.common.api.ApiV2Endpoints import org.knora.webapi.slice.infrastructure.api.PrometheusRoutes import org.knora.webapi.slice.shacl.api.ShaclEndpoints +import org.knora.webapi.slice.v3.api.ApiV3Endpoints object MetricsServer { - private val metricsServer - : ZIO[AdminApiEndpoints & ApiV2Endpoints & KnoraApi & ShaclEndpoints & PrometheusRoutes & Server, Nothing, Unit] = + private val metricsServer = for { docs <- DocsServer.docsEndpoints.map(endpoints => ZioHttpInterpreter().toHttp(endpoints)) prometheus <- ZIO.service[PrometheusRoutes] @@ -37,7 +37,7 @@ object MetricsServer { } yield () type MetricsServerEnv = KnoraApi & State & InstrumentationServerConfig & ApiV2Endpoints & ShaclEndpoints & - AdminApiEndpoints + AdminApiEndpoints & ApiV3Endpoints val make: ZIO[MetricsServerEnv, Throwable, Unit] = for { @@ -45,6 +45,7 @@ object MetricsServer { apiV2Endpoints <- ZIO.service[ApiV2Endpoints] adminApiEndpoints <- ZIO.service[AdminApiEndpoints] shaclApiEndpoints <- ZIO.service[ShaclEndpoints] + apiV3Endpoints <- ZIO.service[ApiV3Endpoints] config <- ZIO.service[InstrumentationServerConfig] port = config.port interval = config.interval @@ -58,6 +59,7 @@ object MetricsServer { ZLayer.succeed(adminApiEndpoints), ZLayer.succeed(apiV2Endpoints), ZLayer.succeed(shaclApiEndpoints), + ZLayer.succeed(apiV3Endpoints), Server.defaultWithPort(port), prometheus.publisherLayer, ZLayer.succeed(metricsConfig) >>> prometheus.prometheusLayer, @@ -77,7 +79,8 @@ object DocsServer { apiV2 <- ZIO.serviceWith[ApiV2Endpoints](_.endpoints) admin <- ZIO.serviceWith[AdminApiEndpoints](_.endpoints) shacl <- ZIO.serviceWith[ShaclEndpoints](_.endpoints) - allEndpoints = List(apiV2, admin, shacl).flatten + apiV3 <- ZIO.serviceWith[ApiV3Endpoints](_.endpoints) + allEndpoints = List(apiV2, admin, shacl, apiV3).flatten info = Info( title = "DSP-API", version = BuildInfo.version, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Module.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Module.scala new file mode 100644 index 00000000000..466d187cb3a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Module.scala @@ -0,0 +1,56 @@ +/* + * Copyright © 2021 - 2025 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.v3 + +import zio.URLayer + +import org.knora.webapi.config.AppConfig +import org.knora.webapi.config.Features +import org.knora.webapi.responders.IriService +import org.knora.webapi.responders.admin.ListsResponder +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.common.api.AuthorizationRestService +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.common.api.KnoraResponseRenderer +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter +import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper +import org.knora.webapi.slice.common.service.IriConverter +import org.knora.webapi.slice.infrastructure.CacheManager +import org.knora.webapi.slice.lists.domain.ListsService +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.slice.v3.api.ApiV3Endpoints +import org.knora.webapi.slice.v3.projects.ProjectsModule +import org.knora.webapi.store.triplestore.api.TriplestoreService + +object V3Module { self => + type Dependencies = + // format: off + AppConfig & + AuthorizationRestService & + BaseEndpoints & + CacheManager & + Features & + HandlerMapper & + IriConverter & + IriService & + KnoraProjectService & + KnoraResponseRenderer & + ListsResponder & + ListsService & + OntologyCache & + OntologyRepo & + PredicateObjectMapper & + TapirToPekkoInterpreter & + TriplestoreService + // format: on + + type Provided = V3Routes & ProjectsModule.Provided & ApiV3Endpoints + + val layer: URLayer[self.Dependencies, self.Provided] = + ProjectsModule.layer >+> (V3Routes.layer ++ ApiV3Endpoints.layer) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Routes.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Routes.scala new file mode 100644 index 00000000000..77e70d7cf96 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/V3Routes.scala @@ -0,0 +1,25 @@ +/* + * Copyright © 2021 - 2025 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.v3 + +import org.apache.pekko.http.scaladsl.server.Directives.* +import org.apache.pekko.http.scaladsl.server.Route +import zio.ZLayer + +import org.knora.webapi.slice.v3.projects.api.ProjectsApiRoutes + +final case class V3Routes( + projectsApiRoutes: ProjectsApiRoutes, +) { + + val routes: Route = + // Remove v3 prefix since endpoints now include full path for proper OpenAPI documentation + projectsApiRoutes.routes +} + +object V3Routes { + val layer = ZLayer.derive[V3Routes] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/api/ApiV3Endpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/api/ApiV3Endpoints.scala new file mode 100644 index 00000000000..d964a84fc34 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/api/ApiV3Endpoints.scala @@ -0,0 +1,23 @@ +/* + * Copyright © 2021 - 2025 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.v3.api + +import sttp.tapir.AnyEndpoint +import zio.ZLayer + +import org.knora.webapi.slice.v3.projects.api.ProjectsEndpoints + +final case class ApiV3Endpoints( + private val projectsEndpoints: ProjectsEndpoints, +) { + + val endpoints: Seq[AnyEndpoint] = + projectsEndpoints.endpoints +} + +object ApiV3Endpoints { + val layer = ZLayer.derive[ApiV3Endpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/ProjectsModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/ProjectsModule.scala new file mode 100644 index 00000000000..83d1ecbe2b4 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/ProjectsModule.scala @@ -0,0 +1,57 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects + +import zio.URLayer + +import org.knora.webapi.config.AppConfig +import org.knora.webapi.config.Features +import org.knora.webapi.responders.IriService +import org.knora.webapi.responders.admin.ListsResponder +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.common.api.AuthorizationRestService +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.common.api.KnoraResponseRenderer +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter +import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper +import org.knora.webapi.slice.common.service.IriConverter +import org.knora.webapi.slice.infrastructure.CacheManager +import org.knora.webapi.slice.lists.domain.ListsService +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.slice.v3.projects.api.ProjectsApiModule +import org.knora.webapi.slice.v3.projects.domain.ProjectsDomainModule +import org.knora.webapi.slice.v3.projects.repo.ProjectsRepoModule +import org.knora.webapi.store.triplestore.api.TriplestoreService + +object ProjectsModule { self => + type Dependencies = + // format: off + AppConfig & + AuthorizationRestService & + BaseEndpoints & + CacheManager & + Features & + HandlerMapper & + IriConverter & + IriService & + KnoraProjectService & + KnoraResponseRenderer & + ListsResponder & + ListsService & + OntologyCache & + OntologyRepo & + PredicateObjectMapper & + TapirToPekkoInterpreter & + TriplestoreService + // format: on + + type Provided = ProjectsApiModule.Provided + + val layer: URLayer[self.Dependencies, self.Provided] = + ProjectsRepoModule.layer >>> ProjectsDomainModule.layer >>> ProjectsApiModule.layer +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiModule.scala new file mode 100644 index 00000000000..00dfbbb242c --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiModule.scala @@ -0,0 +1,34 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api + +import zio.* + +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter +import org.knora.webapi.slice.v3.projects.api.service.ProjectsRestService +import org.knora.webapi.slice.v3.projects.domain.service.ProjectsService + +object ProjectsApiModule { self => + type Dependencies = + // format: off + BaseEndpoints & + HandlerMapper & + ProjectsService & + TapirToPekkoInterpreter + // format: on + + type Provided = ProjectsApiRoutes & ProjectsEndpoints + + val layer: URLayer[self.Dependencies, self.Provided] = + ZLayer.makeSome[self.Dependencies, self.Provided]( + ProjectsEndpoints.layer, + ProjectsRestService.layer, + ProjectsEndpointsHandler.layer, + ProjectsApiRoutes.layer, + ) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiRoutes.scala new file mode 100644 index 00000000000..a7a9bb04a37 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsApiRoutes.scala @@ -0,0 +1,28 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api + +import org.apache.pekko.http.scaladsl.server.Directives.* +import org.apache.pekko.http.scaladsl.server.Route +import zio.ZLayer + +import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter + +final case class ProjectsApiRoutes( + projectsEndpointsHandler: ProjectsEndpointsHandler, + tapirToPekko: TapirToPekkoInterpreter, +) { + + val routes: Route = + // Remove path prefix since endpoint now includes full path for proper OpenAPI documentation + concat( + projectsEndpointsHandler.allHandlers.map(tapirToPekko.toRoute(_)): _*, + ) +} + +object ProjectsApiRoutes { + val layer = ZLayer.derive[ProjectsApiRoutes] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpoints.scala new file mode 100644 index 00000000000..e4ea617d69f --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpoints.scala @@ -0,0 +1,50 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api + +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.zio.jsonBody +import zio.ZLayer + +import org.knora.webapi.slice.common.api.BaseEndpoints +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ProjectResponseDto +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ProjectShortcodeParam +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ResourceCountsResponseDto +import org.knora.webapi.slice.v3.projects.api.model.V3CommonErrors +import org.knora.webapi.slice.v3.projects.api.model.V3ErrorResponse + +final case class ProjectsEndpoints( + baseEndpoints: BaseEndpoints, +) { + + object Public { + // Include full path in endpoint definition for proper OpenAPI documentation + val getProjectById = endpoint.get + .in("v3" / "projects" / path[ProjectShortcodeParam]("shortcode")) + .out(jsonBody[ProjectResponseDto]) + .errorOut(V3CommonErrors.extendedV3ErrorOut) + .description("Returns project information by shortcode") + .tag("V3 Projects") + + val getResourceCounts = endpoint.get + .in("v3" / "projects" / path[ProjectShortcodeParam]("shortcode") / "resource-counts") + .out(jsonBody[ResourceCountsResponseDto]) + .errorOut(V3CommonErrors.minimalV3ErrorOut) + .description("Returns resource instance counts by class for a project") + .tag("V3 Projects") + } + + val endpoints: Seq[AnyEndpoint] = + Seq( + Public.getProjectById, + Public.getResourceCounts, + ).map(_.tag("V3 Projects")) +} + +object ProjectsEndpoints { + val layer = ZLayer.derive[ProjectsEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpointsHandler.scala new file mode 100644 index 00000000000..6d4d82fdf9e --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/ProjectsEndpointsHandler.scala @@ -0,0 +1,78 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api + +import sttp.capabilities.pekko.PekkoStreams +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint.Full +import zio.Task +import zio.ZIO +import zio.ZLayer + +import scala.concurrent.Future + +import org.knora.webapi.routing.UnsafeZioRun +import org.knora.webapi.slice.common.api.HandlerMapper +import org.knora.webapi.slice.v3.projects.api.model.V3ErrorResponse +import org.knora.webapi.slice.v3.projects.api.model.V3ProjectException +import org.knora.webapi.slice.v3.projects.api.service.ProjectsRestService + +/** + * Custom endpoint handler for V3 endpoints that handles V3ErrorResponse instead of RequestRejectedException. + */ +case class V3PublicEndpointHandler[INPUT, OUTPUT]( + endpoint: Endpoint[Unit, INPUT, V3ErrorResponse, OUTPUT, PekkoStreams], + handler: INPUT => Task[OUTPUT], +) + +final case class ProjectsEndpointsHandler( + projectsEndpoints: ProjectsEndpoints, + restService: ProjectsRestService, + mapper: HandlerMapper, +)(implicit r: zio.Runtime[Any]) { + + def mapV3PublicEndpointHandler[INPUT, OUTPUT]( + handlerAndEndpoint: V3PublicEndpointHandler[INPUT, OUTPUT], + ): Full[Unit, Unit, INPUT, V3ErrorResponse, OUTPUT, PekkoStreams, Future] = + handlerAndEndpoint.endpoint.serverLogic[Future](in => runV3ToFuture(handlerAndEndpoint.handler(in))) + + def runV3ToFuture[OUTPUT](zio: Task[OUTPUT]): Future[Either[V3ErrorResponse, OUTPUT]] = + UnsafeZioRun.runToFuture( + zio.refineOrDie { case e: V3ProjectException => + mapHttpStatusToErrorResponse(e.toV3ErrorResponse("generated-request-id")) + }.either, + ) + + private def mapHttpStatusToErrorResponse(errorResponse: V3ErrorResponse): V3ErrorResponse = + // The oneOf in Tapir will automatically route to the correct status code variant + // based on the HTTP status code returned by the server logic + errorResponse + + val getProjectByIdHandler = + V3PublicEndpointHandler(projectsEndpoints.Public.getProjectById, restService.findProjectByShortcode) + + val getResourceCountsHandler = + V3PublicEndpointHandler(projectsEndpoints.Public.getResourceCounts, restService.findResourceCountsByShortcode) + + private val handlers = + List( + mapV3PublicEndpointHandler(getProjectByIdHandler), + mapV3PublicEndpointHandler(getResourceCountsHandler), + ) + + val allHandlers = handlers +} + +object ProjectsEndpointsHandler { + val layer = ZLayer.fromZIO( + for { + endpoints <- ZIO.service[ProjectsEndpoints] + restService <- ZIO.service[ProjectsRestService] + mapper <- ZIO.service[HandlerMapper] + r <- ZIO.runtime[Any] + } yield ProjectsEndpointsHandler(endpoints, restService, mapper)(r), + ) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/ProjectsDto.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/ProjectsDto.scala new file mode 100644 index 00000000000..f12bccb34b5 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/ProjectsDto.scala @@ -0,0 +1,166 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api.model + +import sttp.tapir.Codec +import sttp.tapir.CodecFormat +import sttp.tapir.Schema +import sttp.tapir.Validator +import zio.json.* + +import org.knora.webapi.slice.v3.projects.domain.model.* +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* + +object ProjectsDto { + + final case class ProjectShortcodeParam(value: String) { + def toDomain: Either[String, ProjectShortcode] = ProjectShortcode.from(value) + } + + object ProjectShortcodeParam { + private val shortcodeValidator: Validator[String] = Validator.pattern("^[0-9A-F]{4}$") + + def from(value: String): Either[String, ProjectShortcodeParam] = + shortcodeValidator.apply(value) match { + case Nil => Right(ProjectShortcodeParam(value)) + case errors => + Left( + s"Invalid project shortcode format. Expected 4-character uppercase hex string, got: $value. Errors: ${errors.mkString(", ")}", + ) + } + + given Codec[String, ProjectShortcodeParam, CodecFormat.TextPlain] = + Codec + .parsedString(ProjectShortcodeParam.apply) + .validate(shortcodeValidator.contramap(_.value)) + + given Schema[ProjectShortcodeParam] = Schema.string + .description( + "Project shortcode - a 4-character uppercase hexadecimal identifier (e.g., '0001', '08FF'). Must be exactly 4 uppercase hexadecimal characters.", + ) + .encodedExample("08FF") + + given JsonCodec[ProjectShortcodeParam] = JsonCodec.string.transformOrFail( + from, + _.value, + ) + } + + final case class ProjectResponseDto( + shortcode: String, + shortname: String, + iri: String, + fullName: Option[String], + description: Map[String, String], + status: Boolean, + lists: List[ListPreviewResponseDto], + ontologies: List[OntologyResponseDto], + ) + + final case class ResourceCountsResponseDto( + counts: List[OntologyResourceCountsResponseDto], + ) + + final case class ListPreviewResponseDto( + iri: String, + labels: Map[String, String], + ) + + final case class OntologyResponseDto( + iri: String, + label: String, + classes: List[AvailableClassResponseDto], + ) + + final case class AvailableClassResponseDto( + iri: String, + labels: Map[String, String], + ) + + final case class OntologyResourceCountsResponseDto( + ontologyLabel: String, + classes: List[ClassCountResponseDto], + ) + + final case class ClassCountResponseDto( + iri: String, + instanceCount: Int, + ) + + implicit val availableClassResponseDtoCodec: JsonCodec[AvailableClassResponseDto] = + DeriveJsonCodec.gen[AvailableClassResponseDto] + implicit val classCountResponseDtoCodec: JsonCodec[ClassCountResponseDto] = DeriveJsonCodec.gen[ClassCountResponseDto] + implicit val ontologyResourceCountsResponseDtoCodec: JsonCodec[OntologyResourceCountsResponseDto] = + DeriveJsonCodec.gen[OntologyResourceCountsResponseDto] + implicit val resourceCountsResponseDtoCodec: JsonCodec[ResourceCountsResponseDto] = + DeriveJsonCodec.gen[ResourceCountsResponseDto] + implicit val ontologyResponseDtoCodec: JsonCodec[OntologyResponseDto] = DeriveJsonCodec.gen[OntologyResponseDto] + implicit val listPreviewResponseDtoCodec: JsonCodec[ListPreviewResponseDto] = + DeriveJsonCodec.gen[ListPreviewResponseDto] + implicit val projectResponseDtoCodec: JsonCodec[ProjectResponseDto] = DeriveJsonCodec.gen[ProjectResponseDto] + + object ProjectResponseDto { + def from(project: ProjectInfo): ProjectResponseDto = + ProjectResponseDto( + shortcode = project.shortcode.value, + shortname = project.shortname.value, + iri = project.iri.value, + fullName = project.fullName, + description = MultilingualText.toMap(project.description), + status = project.status, + lists = project.lists.map(ListPreviewResponseDto.from), + ontologies = project.ontologies.map(OntologyResponseDto.from), + ) + } + + object ResourceCountsResponseDto { + def from(counts: List[OntologyResourceCounts]): ResourceCountsResponseDto = + ResourceCountsResponseDto( + counts = counts.map(OntologyResourceCountsResponseDto.from), + ) + } + + object ListPreviewResponseDto { + def from(listPreview: ListPreview): ListPreviewResponseDto = + ListPreviewResponseDto( + iri = listPreview.iri.value, + labels = MultilingualText.toMap(listPreview.labels), + ) + } + + object OntologyResponseDto { + def from(ontology: OntologyWithClasses): OntologyResponseDto = + OntologyResponseDto( + iri = ontology.iri.value, + label = ontology.label, + classes = ontology.classes.map(AvailableClassResponseDto.from), + ) + } + + object AvailableClassResponseDto { + def from(availableClass: AvailableClass): AvailableClassResponseDto = + AvailableClassResponseDto( + iri = availableClass.iri.value, + labels = MultilingualText.toMap(availableClass.labels), + ) + } + + object OntologyResourceCountsResponseDto { + def from(counts: OntologyResourceCounts): OntologyResourceCountsResponseDto = + OntologyResourceCountsResponseDto( + ontologyLabel = counts.ontologyLabel, + classes = counts.classes.map(ClassCountResponseDto.from), + ) + } + + object ClassCountResponseDto { + def from(classCount: ClassCount): ClassCountResponseDto = + ClassCountResponseDto( + iri = classCount.iri.value, + instanceCount = classCount.instanceCount, + ) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3CommonErrors.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3CommonErrors.scala new file mode 100644 index 00000000000..ef01b7d78b2 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3CommonErrors.scala @@ -0,0 +1,128 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api.model + +import sttp.model.StatusCode +import sttp.tapir.* +import sttp.tapir.generic.auto.* +import sttp.tapir.json.zio.jsonBody + +/** + * Common reusable error out type definitions for V3 API endpoints. + * These error types can be composed together to avoid duplication across endpoints. + */ +object V3CommonErrors { + + /** + * Standard V3 error variant for bad requests (400). + * Covers invalid parameter formats, validation errors, etc. + */ + val badRequestError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.BadRequest, + jsonBody[V3ErrorResponse] + .description("Invalid request parameter") + .example(V3ErrorExamples.invalidProjectId), + ) + + /** + * Standard V3 error variant for not found (404). + * Covers missing resources, projects, etc. + */ + val notFoundError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.NotFound, + jsonBody[V3ErrorResponse] + .description("Resource not found") + .example(V3ErrorExamples.projectNotFound), + ) + + /** + * Standard V3 error variant for request timeout (408). + * Covers slow queries, external service timeouts, etc. + */ + val timeoutError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.RequestTimeout, + jsonBody[V3ErrorResponse] + .description("Request timeout") + .example(V3ErrorExamples.requestTimeout), + ) + + /** + * Standard V3 error variant for internal server errors (500). + * Covers SPARQL failures, unexpected exceptions, etc. + */ + val internalServerError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.InternalServerError, + jsonBody[V3ErrorResponse] + .description("Internal server error") + .example(V3ErrorExamples.sparqlQueryFailed), + ) + + /** + * Standard V3 error variant for service unavailable (503). + * Covers external service failures, maintenance, etc. + */ + val serviceUnavailableError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.ServiceUnavailable, + jsonBody[V3ErrorResponse] + .description("Service unavailable") + .example(V3ErrorExamples.serviceUnavailable), + ) + + /** + * Standard V3 error variant for partial content (206). + * Used when some data is available but some services failed. + */ + val partialDataError: EndpointOutput.OneOfVariant[V3ErrorResponse] = + oneOfVariant( + StatusCode.PartialContent, + jsonBody[V3ErrorResponse] + .description("Partial data available") + .example(V3ErrorExamples.partialDataAvailable), + ) + + /** + * Complete error out type using common V3 error variants. + * Use this for endpoints that need standard error handling. + */ + val commonV3ErrorOut: EndpointOutput.OneOf[V3ErrorResponse, V3ErrorResponse] = + oneOf[V3ErrorResponse]( + badRequestError, + notFoundError, + timeoutError, + internalServerError, + serviceUnavailableError, + ) + + /** + * Complete error out type including partial data support. + * Use this for endpoints like project info that can gracefully degrade. + */ + val extendedV3ErrorOut: EndpointOutput.OneOf[V3ErrorResponse, V3ErrorResponse] = + oneOf[V3ErrorResponse]( + badRequestError, + notFoundError, + timeoutError, + internalServerError, + serviceUnavailableError, + partialDataError, + ) + + /** + * Minimal error out type for simple endpoints. + * Use this for lightweight endpoints with basic error handling needs. + */ + val minimalV3ErrorOut: EndpointOutput.OneOf[V3ErrorResponse, V3ErrorResponse] = + oneOf[V3ErrorResponse]( + badRequestError, + notFoundError, + internalServerError, + ) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorExamples.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorExamples.scala new file mode 100644 index 00000000000..8ed58d4fe82 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorExamples.scala @@ -0,0 +1,124 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api.model + +import java.time.Instant + +/** + * Centralized V3 error response examples for OpenAPI documentation. + * These examples are used in endpoint error out definitions to provide + * consistent documentation across all V3 API endpoints. + */ +object V3ErrorExamples { + + private val exampleTimestamp = Instant.parse("2025-01-31T12:30:00Z") + + /** + * Example for V3_INVALID_PROJECT_ID error (400). + */ + val invalidProjectId: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_INVALID_PROJECT_ID", + message = "Invalid project shortcode format: 'ABC123'", + details = Some("Project shortcode must be a 4-character hexadecimal code"), + field = Some("shortcode"), + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-12345", + ) + + /** + * Example for V3_PROJECT_NOT_FOUND error (404). + */ + val projectNotFound: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_PROJECT_NOT_FOUND", + message = "Project with identifier '9999' was not found", + details = Some("The project may have been deleted or the identifier is incorrect"), + field = None, + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-67890", + ) + + /** + * Example for V3_TIMEOUT error (408). + */ + val requestTimeout: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_TIMEOUT", + message = "Request timed out after 30000ms", + details = Some("Try reducing the scope of your request or try again later"), + field = None, + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-timeout", + ) + + /** + * Example for V3_SPARQL_QUERY_FAILED error (500). + */ + val sparqlQueryFailed: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_SPARQL_QUERY_FAILED", + message = "Failed to execute instance count query against triplestore", + details = Some("Connection to triplestore failed or query syntax error"), + field = None, + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-error", + ) + + /** + * Example for V3_SERVICE_UNAVAILABLE error (503). + */ + val serviceUnavailable: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_SERVICE_UNAVAILABLE", + message = "Required service 'TriplestoreService' is currently unavailable", + details = Some("Service is temporarily down for maintenance or experiencing high load"), + field = None, + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-unavailable", + ) + + /** + * Example for V3_PARTIAL_DATA_AVAILABLE error (206). + */ + val partialDataAvailable: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_PARTIAL_DATA_AVAILABLE", + message = "Some project data is unavailable due to service failures", + details = Some("Failed services: TriplestoreService"), + field = None, + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-partial", + ) + + /** + * Example for V3_INVALID_PARAMETER error (400). + * Generic parameter validation error. + */ + val invalidParameter: V3ErrorResponse = + V3ErrorResponse( + error = V3Error( + code = "V3_INVALID_PARAMETER", + message = "Invalid parameter 'limit': must be between 1 and 100", + details = Some("Provided value: '150'"), + field = Some("limit"), + ), + timestamp = exampleTimestamp, + requestId = "req-uuid-param", + ) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorModel.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorModel.scala new file mode 100644 index 00000000000..4b68cbe2a09 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/model/V3ErrorModel.scala @@ -0,0 +1,237 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api.model + +import zio.json.* + +import java.time.Instant + +/** + * Standard error response format for V3 API endpoints. + * This is completely independent from the existing DSP-API error handling. + * + * @param error The specific error information + * @param timestamp When the error occurred + * @param requestId Correlation ID for request tracing + */ +final case class V3ErrorResponse( + error: V3Error, + timestamp: Instant, + requestId: String, +) + +/** + * Detailed error information with structured data. + * + * @param code Specific error code (e.g., "V3_PROJECT_NOT_FOUND") + * @param message Human-readable error message + * @param details Optional additional context or debugging information + * @param field Optional field name that caused the error (for validation errors) + */ +final case class V3Error( + code: String, + message: String, + details: Option[String] = None, + field: Option[String] = None, +) + +/** + * V3-specific exceptions for project-related errors. + * These are independent from the existing DSP-API error system. + */ +sealed abstract class V3ProjectException( + val code: String, + val message: String, + val details: Option[String] = None, + val field: Option[String] = None, + val httpStatusCode: Int = 500, +) extends Exception(message) { + def toV3Error: V3Error = V3Error(code, message, details, field) + def toV3ErrorResponse(requestId: String): V3ErrorResponse = V3ErrorResponse(toV3Error, Instant.now(), requestId) +} + +/** + * Exception for when a project is not found. + */ +final case class V3ProjectNotFoundException(projectId: String) + extends V3ProjectException( + code = "V3_PROJECT_NOT_FOUND", + message = s"Project with identifier '$projectId' was not found", + details = Some("The project may have been deleted or the identifier is incorrect"), + httpStatusCode = 404, + ) + +/** + * Exception for invalid project ID formats. + */ +final case class V3InvalidProjectIdException(projectId: String, reason: String) + extends V3ProjectException( + code = "V3_INVALID_PROJECT_ID", + message = s"Invalid project identifier format: '$projectId'", + details = Some(reason), + field = Some("id"), + httpStatusCode = 400, + ) + +/** + * Exception for service unavailability scenarios. + */ +final case class V3ServiceUnavailableException(serviceName: String, cause: String) + extends V3ProjectException( + code = "V3_SERVICE_UNAVAILABLE", + message = s"Required service '$serviceName' is currently unavailable", + details = Some(cause), + httpStatusCode = 503, + ) + +/** + * Exception for partial data availability (when some services fail but others succeed). + */ +final case class V3PartialDataException(missingServices: List[String]) + extends V3ProjectException( + code = "V3_PARTIAL_DATA_AVAILABLE", + message = "Some project data is unavailable due to service failures", + details = Some(s"Failed services: ${missingServices.mkString(", ")}"), + httpStatusCode = 206, + ) + +/** + * Exception for SPARQL query failures. + */ +final case class V3SparqlQueryException(queryType: String, cause: String) + extends V3ProjectException( + code = "V3_SPARQL_QUERY_FAILED", + message = s"Failed to execute $queryType query against triplestore", + details = Some(cause), + httpStatusCode = 500, + ) + +/** + * Exception for request timeouts. + */ +final case class V3RequestTimeoutException(timeoutMs: Long) + extends V3ProjectException( + code = "V3_TIMEOUT", + message = s"Request timed out after ${timeoutMs}ms", + details = Some("Try reducing the scope of your request or try again later"), + httpStatusCode = 408, + ) + +/** + * Exception for invalid parameters. + */ +final case class V3InvalidParameterException(paramName: String, value: String, reason: String) + extends V3ProjectException( + code = "V3_INVALID_PARAMETER", + message = s"Invalid parameter '$paramName': $reason", + details = Some(s"Provided value: '$value'"), + field = Some(paramName), + httpStatusCode = 400, + ) + +/** + * Utility object for creating V3 errors and responses. + */ +object V3ProjectErrors { + + // Error code constants + val PROJECT_NOT_FOUND = "V3_PROJECT_NOT_FOUND" + val INVALID_PROJECT_ID = "V3_INVALID_PROJECT_ID" + val SERVICE_UNAVAILABLE = "V3_SERVICE_UNAVAILABLE" + val PARTIAL_DATA_AVAILABLE = "V3_PARTIAL_DATA_AVAILABLE" + val SPARQL_QUERY_FAILED = "V3_SPARQL_QUERY_FAILED" + val TIMEOUT = "V3_TIMEOUT" + val INVALID_PARAMETER = "V3_INVALID_PARAMETER" + + // Factory methods for common error scenarios + def projectNotFound(projectId: String): V3Error = + V3Error( + code = PROJECT_NOT_FOUND, + message = s"Project with identifier '$projectId' was not found", + details = Some("The project may have been deleted or the identifier is incorrect"), + ) + + def invalidProjectId(projectId: String, reason: String): V3Error = + V3Error( + code = INVALID_PROJECT_ID, + message = s"Invalid project identifier format: '$projectId'", + details = Some(reason), + field = Some("id"), + ) + + def serviceUnavailable(serviceName: String, cause: String): V3Error = + V3Error( + code = SERVICE_UNAVAILABLE, + message = s"Required service '$serviceName' is currently unavailable", + details = Some(cause), + ) + + def partialDataAvailable(missingServices: List[String]): V3Error = + V3Error( + code = PARTIAL_DATA_AVAILABLE, + message = "Some project data is unavailable due to service failures", + details = Some(s"Failed services: ${missingServices.mkString(", ")}"), + ) + + def sparqlQueryFailed(queryType: String, cause: String): V3Error = + V3Error( + code = SPARQL_QUERY_FAILED, + message = s"Failed to execute $queryType query against triplestore", + details = Some(cause), + ) + + def requestTimeout(timeoutMs: Long): V3Error = + V3Error( + code = TIMEOUT, + message = s"Request timed out after ${timeoutMs}ms", + details = Some("Try reducing the scope of your request or try again later"), + ) + + def invalidParameter(paramName: String, value: String, reason: String): V3Error = + V3Error( + code = INVALID_PARAMETER, + message = s"Invalid parameter '$paramName': $reason", + details = Some(s"Provided value: '$value'"), + field = Some(paramName), + ) +} + +// JSON codecs for serialization +object V3ErrorResponse { + implicit val codec: JsonCodec[V3ErrorResponse] = DeriveJsonCodec.gen[V3ErrorResponse] +} + +object V3Error { + implicit val codec: JsonCodec[V3Error] = DeriveJsonCodec.gen[V3Error] +} + +object V3ProjectNotFoundException { + implicit val codec: JsonCodec[V3ProjectNotFoundException] = DeriveJsonCodec.gen[V3ProjectNotFoundException] +} + +object V3InvalidProjectIdException { + implicit val codec: JsonCodec[V3InvalidProjectIdException] = DeriveJsonCodec.gen[V3InvalidProjectIdException] +} + +object V3ServiceUnavailableException { + implicit val codec: JsonCodec[V3ServiceUnavailableException] = DeriveJsonCodec.gen[V3ServiceUnavailableException] +} + +object V3PartialDataException { + implicit val codec: JsonCodec[V3PartialDataException] = DeriveJsonCodec.gen[V3PartialDataException] +} + +object V3SparqlQueryException { + implicit val codec: JsonCodec[V3SparqlQueryException] = DeriveJsonCodec.gen[V3SparqlQueryException] +} + +object V3RequestTimeoutException { + implicit val codec: JsonCodec[V3RequestTimeoutException] = DeriveJsonCodec.gen[V3RequestTimeoutException] +} + +object V3InvalidParameterException { + implicit val codec: JsonCodec[V3InvalidParameterException] = DeriveJsonCodec.gen[V3InvalidParameterException] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/service/ProjectsRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/service/ProjectsRestService.scala new file mode 100644 index 00000000000..8eb65bbb425 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/api/service/ProjectsRestService.scala @@ -0,0 +1,51 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.api.service + +import zio.* + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ProjectResponseDto +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ProjectShortcodeParam +import org.knora.webapi.slice.v3.projects.api.model.ProjectsDto.ResourceCountsResponseDto +import org.knora.webapi.slice.v3.projects.api.model.V3InvalidProjectIdException +import org.knora.webapi.slice.v3.projects.api.model.V3ProjectNotFoundException +import org.knora.webapi.slice.v3.projects.domain.service.ProjectsService + +final case class ProjectsRestService( + projectsService: ProjectsService, +) { + + def findProjectByShortcode(shortcode: ProjectShortcodeParam): Task[ProjectResponseDto] = + for { + projectIri <- findProjectIri(shortcode) + project <- + projectsService.findProjectInfoByIri(projectIri).someOrFail(V3ProjectNotFoundException(shortcode.value)) + _ <- projectsService.warmResourceCountsCache(projectIri) + _ <- ZIO.logDebug(s"Cache Reload Triggered for project ${projectIri.value}") + } yield ProjectResponseDto.from(project) + + def findResourceCountsByShortcode(shortcode: ProjectShortcodeParam): Task[ResourceCountsResponseDto] = + for { + projectIri <- findProjectIri(shortcode) + counts <- + projectsService.findResourceCountsById(projectIri).someOrFail(V3ProjectNotFoundException(shortcode.value)) + } yield ResourceCountsResponseDto.from(counts) + + private def findProjectIri(shortcode: ProjectShortcodeParam): Task[ProjectIri] = + for { + knoraShortcode <- + ZIO + .fromEither(shortcode.toDomain.flatMap(domainShortcode => Shortcode.from(domainShortcode.value))) + .mapError(_ => V3InvalidProjectIdException(shortcode.value, "Must be a 4-character hexadecimal shortcode")) + projectIri <- projectsService.findProjectIriByShortcode(knoraShortcode) + } yield projectIri +} + +object ProjectsRestService { + val layer = ZLayer.derive[ProjectsRestService] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/ProjectsDomainModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/ProjectsDomainModule.scala new file mode 100644 index 00000000000..7e43ee994ae --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/ProjectsDomainModule.scala @@ -0,0 +1,20 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.domain + +import zio.URLayer + +import org.knora.webapi.slice.v3.projects.domain.model.ProjectsRepo +import org.knora.webapi.slice.v3.projects.domain.service.ProjectsService + +object ProjectsDomainModule { self => + type Dependencies = ProjectsRepo + + type Provided = ProjectsService + + val layer: URLayer[self.Dependencies, self.Provided] = + ProjectsService.layer +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/DomainTypes.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/DomainTypes.scala new file mode 100644 index 00000000000..eaacb23ad26 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/DomainTypes.scala @@ -0,0 +1,160 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.domain.model + +object DomainTypes { + + opaque type ProjectIri = String + opaque type OntologyIri = String + opaque type ListIri = String + opaque type ClassIri = String + opaque type ProjectShortcode = String + opaque type ProjectShortname = String + opaque type LanguageCode = String + + object ProjectIri { + // Projects: http://rdfh.ch/projects/PROJECT_UUID (lenient on UUID format) + private val projectIriPattern = """^http://rdfh\.ch/projects/.+$""".r + + def from(value: String): Either[String, ProjectIri] = + projectIriPattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => Left(s"Invalid project IRI format. Expected 'http://rdfh.ch/projects/PROJECT_UUID', got: $value") + } + + def unsafeFrom(value: String): ProjectIri = value + + extension (iri: ProjectIri) def value: String = iri + } + + object OntologyIri { + // Internal ontology IRIs: http://www.knora.org/ontology/PROJECT_SHORTCODE/ONTOLOGY_NAME + // or shared: http://www.knora.org/ontology/shared/ONTOLOGY_NAME + private val internalOntologyPattern = + """^http://www\.knora\.org/ontology/(?:[0-9A-F]{4}|shared)/[a-zA-Z][a-zA-Z0-9_-]*$""".r + + def from(value: String): Either[String, OntologyIri] = + internalOntologyPattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => + Left( + s"Invalid internal ontology IRI format. Expected 'http://www.knora.org/ontology/SHORTCODE/NAME' or 'http://www.knora.org/ontology/shared/NAME', got: $value", + ) + } + + def unsafeFrom(value: String): OntologyIri = value + + extension (iri: OntologyIri) def value: String = iri + } + + object ListIri { + // Lists: http://rdfh.ch/lists/PROJECT_SHORTCODE/LIST_UUID (lenient on UUID format) + private val listIriPattern = """^http://rdfh\.ch/lists/[0-9A-F]{4}/.+$""".r + + def from(value: String): Either[String, ListIri] = + listIriPattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => + Left(s"Invalid list IRI format. Expected 'http://rdfh.ch/lists/PROJECT_SHORTCODE/LIST_UUID', got: $value") + } + + def unsafeFrom(value: String): ListIri = value + + extension (iri: ListIri) def value: String = iri + } + + object ClassIri { + // Classes (ontology entities): http://www.knora.org/ontology/PROJECT_SHORTCODE/ONTOLOGY_NAME#CLASS_NAME + // or built-in: http://www.knora.org/ontology/knora-base#ClassName + // or shared: http://www.knora.org/ontology/shared/ONTOLOGY_NAME#CLASS_NAME + private val classIriPattern = + """^http://www\.knora\.org/ontology/(?:[0-9A-F]{4}|shared|knora-base)/[a-zA-Z][a-zA-Z0-9_-]*#[a-zA-Z][a-zA-Z0-9_-]*$""".r + + def from(value: String): Either[String, ClassIri] = + classIriPattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => + Left( + s"Invalid class IRI format. Expected 'http://www.knora.org/ontology/SHORTCODE/ONTOLOGY#CLASS', got: $value", + ) + } + + def unsafeFrom(value: String): ClassIri = value + + extension (iri: ClassIri) def value: String = iri + } + + object ProjectShortcode { + // Project shortcode: 4-character uppercase hex string (e.g., "0001", "08FF") + private val shortcodePattern = """^[0-9A-F]{4}$""".r + + def from(value: String): Either[String, ProjectShortcode] = + shortcodePattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => Left(s"Invalid project shortcode format. Expected 4-character uppercase hex string, got: $value") + } + + def unsafeFrom(value: String): ProjectShortcode = value + + extension (shortcode: ProjectShortcode) def value: String = shortcode + } + + object ProjectShortname { + // Project shortname: must be a valid XML NCName (starts with letter/underscore, followed by letters/digits/hyphens/underscores/periods) + private val ncNamePattern = """^[a-zA-Z_][a-zA-Z0-9_\-\.]*$""".r + + def from(value: String): Either[String, ProjectShortname] = + if (value.nonEmpty && ncNamePattern.matches(value)) { + Right(value) + } else { + Left(s"Invalid project shortname format. Must be a valid XML NCName, got: $value") + } + + def unsafeFrom(value: String): ProjectShortname = value + + extension (shortname: ProjectShortname) def value: String = shortname + } + + object LanguageCode { + // ISO 639-1 language codes: 2-letter lowercase codes (e.g., "en", "de", "fr") + private val languageCodePattern = """^[a-z]{2}$""".r + + def from(value: String): Either[String, LanguageCode] = + languageCodePattern.findFirstIn(value) match { + case Some(_) => Right(value) + case None => Left(s"Invalid language code format. Expected ISO 639-1 code (2 lowercase letters), got: $value") + } + + def unsafeFrom(value: String): LanguageCode = value + + extension (code: LanguageCode) def value: String = code + } + + // Type alias for multilingual content - maps language codes to text + type MultilingualText = Map[LanguageCode, String] + + object MultilingualText { + def from(textMap: Map[String, String]): Either[String, MultilingualText] = { + val validatedEntries = textMap.toList.map { case (lang, text) => + LanguageCode.from(lang).map(_ -> text) + } + + val errors = validatedEntries.collect { case Left(error) => error } + if (errors.nonEmpty) { + Left(s"Invalid language codes: ${errors.mkString(", ")}") + } else { + val validated = validatedEntries.collect { case Right(entry) => entry }.toMap + Right(validated) + } + } + + def unsafeFrom(textMap: Map[String, String]): MultilingualText = + textMap.map { case (lang, text) => LanguageCode.unsafeFrom(lang) -> text } + + def toMap(multilingualText: MultilingualText): Map[String, String] = + multilingualText.map { case (lang, text) => (lang: String) -> text } + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/Project.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/Project.scala new file mode 100644 index 00000000000..593cf6e91be --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/Project.scala @@ -0,0 +1,50 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.domain.model + +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* + +final case class ProjectInfo( + shortcode: ProjectShortcode, + shortname: ProjectShortname, + iri: ProjectIri, + fullName: Option[String], + description: MultilingualText, + status: Boolean, + lists: List[ListPreview], + ontologies: List[OntologyWithClasses], +) + +final case class ListPreview( + iri: ListIri, + labels: MultilingualText, +) + +final case class OntologyWithClasses( + iri: OntologyIri, + label: String, + classes: List[AvailableClass], +) + +final case class AvailableClass( + iri: ClassIri, + labels: MultilingualText, +) + +final case class OntologyResourceCounts( + ontologyLabel: String, + classes: List[ClassCount], +) + +final case class ClassCount( + iri: ClassIri, + instanceCount: Int, +) + +final case class Ontology( + iri: OntologyIri, + label: String, +) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/ProjectsRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/ProjectsRepo.scala new file mode 100644 index 00000000000..aa95922ce16 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/model/ProjectsRepo.scala @@ -0,0 +1,35 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.domain.model + +import zio.Task + +import org.knora.webapi.messages.admin.responder.listsmessages.ListsGetResponseADM +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyV2 +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* + +trait ProjectsRepo { + + def findProjectByIri(id: ProjectIri): Task[Option[KnoraProject]] + + def findProjectByShortcode(shortcode: Shortcode): Task[Option[KnoraProject]] + + def findOntologiesByProject(projectId: ProjectIri): Task[List[ReadOntologyV2]] + + def findListsByProject(projectId: ProjectIri): Task[ListsGetResponseADM] + + def countInstancesByClasses( + shortcode: Shortcode, + shortname: Shortname, + classIris: List[String], + ): Task[Map[String, Int]] + + def getClassesFromOntology(ontologyIri: OntologyIri): Task[List[(String, Map[String, String])]] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/service/ProjectsService.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/service/ProjectsService.scala new file mode 100644 index 00000000000..0788cbbbe7e --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/domain/service/ProjectsService.scala @@ -0,0 +1,242 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.domain.service + +import zio.* + +import org.knora.webapi.messages.admin.responder.listsmessages.ListRootNodeInfoADM +import org.knora.webapi.messages.store.triplestoremessages.StringLiteralSequenceV2 +import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyV2 +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Description +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.v3.projects.api.model.V3ProjectNotFoundException +import org.knora.webapi.slice.v3.projects.domain.model.* +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* + +final case class ProjectsService( + projectsRepo: ProjectsRepo, +) { + + def findProjectInfoByIri(id: ProjectIri): Task[Option[ProjectInfo]] = + projectsRepo.findProjectByIri(id).flatMap(ZIO.foreach(_)(convertToV3ProjectInfo)) + + def findResourceCountsById(id: ProjectIri): Task[Option[List[OntologyResourceCounts]]] = + projectsRepo.findProjectByIri(id).flatMap(ZIO.foreach(_)(convertToResourceCounts)) + + def warmResourceCountsCache(id: ProjectIri): Task[Unit] = + projectsRepo + .findProjectByIri(id) + .flatMap(ZIO.foreach(_)(warmCacheForProject)) + .forkDaemon + .unit + + private def warmCacheForProject(knoraProject: KnoraProject): Task[Unit] = + for { + ontologies <- fetchProjectOntologies(knoraProject.id) + _ <- ZIO.foreachDiscard(ontologies)(warmCacheForOntology(_, knoraProject)) + } yield () + + private def warmCacheForOntology(ontology: Ontology, knoraProject: KnoraProject): Task[Unit] = + for { + classInfo <- projectsRepo.getClassesFromOntology(ontology.iri) + classIris = extractClassIris(classInfo) + _ <- ZIO.when(classIris.nonEmpty) { + projectsRepo.countInstancesByClasses(knoraProject.shortcode, knoraProject.shortname, classIris) + } + } yield () + + def findProjectIriByShortcode(shortcode: Shortcode): Task[ProjectIri] = + projectsRepo + .findProjectByShortcode(shortcode) + .someOrFail(V3ProjectNotFoundException(shortcode.value)) + .map(_.id) + + private def fetchProjectOntologies(projectId: ProjectIri): Task[List[Ontology]] = + for { + readOntologies <- projectsRepo.findOntologiesByProject(projectId) + ontologyModels <- ZIO.foreach(readOntologies)(createOntologyFromReadOntologyV2) + } yield ontologyModels + + private def fetchProjectLists(projectId: ProjectIri): Task[List[ListPreview]] = + for { + listsResponse <- projectsRepo.findListsByProject(projectId) + lists <- ZIO.foreach(listsResponse.lists.toList)(createListPreviewFromADM) + } yield lists + + private def convertToV3ProjectInfo( + knoraProject: KnoraProject, + ): Task[ProjectInfo] = + for { + ontologies <- fetchProjectOntologies(knoraProject.id) + lists <- fetchProjectLists(knoraProject.id) + ontologiesWithClasses <- enrichOntologiesWithClasses(ontologies) + projectInfo <- convertKnoraProjectToV3Info(knoraProject, ontologiesWithClasses, lists) + } yield projectInfo + + private def enrichOntologiesWithClasses(ontologies: List[Ontology]): Task[List[OntologyWithClasses]] = + ZIO.collectAll(ontologies.map(fetchOntologyWithClasses)) + + private def convertToResourceCounts( + knoraProject: KnoraProject, + ): Task[List[OntologyResourceCounts]] = + for { + ontologies <- fetchProjectOntologies(knoraProject.id) + resourceCounts <- generateAllResourceCounts(ontologies, knoraProject) + } yield resourceCounts + + private def generateAllResourceCounts( + ontologies: List[Ontology], + knoraProject: KnoraProject, + ): Task[List[OntologyResourceCounts]] = + ZIO.collectAll(ontologies.map(generateResourceCountsForOntology(_, knoraProject))) + + private def fetchOntologyWithClasses(ontology: Ontology): Task[OntologyWithClasses] = + for { + classInfo <- projectsRepo.getClassesFromOntology(ontology.iri) + availableClasses <- ZIO.foreach(classInfo)(validateAndCreateAvailableClass) + ontologyWithClasses = OntologyWithClasses( + iri = ontology.iri, + label = ontology.label, + classes = availableClasses, + ) + } yield ontologyWithClasses + + private def validateAndCreateAvailableClass(classInfo: (String, Map[String, String])): Task[AvailableClass] = { + val (classIri, labels) = classInfo + for { + classIriValidated <- ZIO + .fromEither(ClassIri.from(classIri)) + .mapError(msg => new IllegalArgumentException(s"Invalid class IRI: $msg")) + validatedLabels <- ZIO + .fromEither(MultilingualText.from(labels)) + .mapError(msg => new IllegalArgumentException(s"Invalid multilingual labels: $msg")) + } yield AvailableClass(iri = classIriValidated, labels = validatedLabels) + } + + private def generateResourceCountsForOntology( + ontology: Ontology, + knoraProject: KnoraProject, + ): Task[OntologyResourceCounts] = + for { + classInfo <- projectsRepo.getClassesFromOntology(ontology.iri) + classIris = extractClassIris(classInfo) + instanceCounts <- projectsRepo.countInstancesByClasses(knoraProject.shortcode, knoraProject.shortname, classIris) + classCounts <- ZIO.foreach(classInfo)(createClassCountFromInfo(_, instanceCounts)) + resourceCounts = OntologyResourceCounts(ontologyLabel = ontology.label, classes = classCounts) + } yield resourceCounts + + private def extractClassIris(classInfo: List[(String, Map[String, String])]): List[String] = + classInfo.map(_._1) + + private def createClassCountFromInfo( + classInfo: (String, Map[String, String]), + instanceCounts: Map[String, Int], + ): Task[ClassCount] = { + val (classIri, _) = classInfo + for { + classIriValidated <- ZIO + .fromEither(ClassIri.from(classIri)) + .mapError(msg => new IllegalArgumentException(s"Invalid class IRI: $msg")) + count = instanceCounts.getOrElse(classIri, 0) + } yield ClassCount(iri = classIriValidated, instanceCount = count) + } + + private def convertKnoraProjectToV3Info( + knoraProject: KnoraProject, + ontologies: List[OntologyWithClasses], + lists: List[ListPreview], + ): Task[ProjectInfo] = + for { + projectIri <- ZIO + .fromEither(DomainTypes.ProjectIri.from(knoraProject.id.value)) + .mapError(msg => new IllegalArgumentException(s"Invalid project IRI: $msg")) + descriptions <- convertMultiLangDescriptions(knoraProject.description.toList) + shortcodeValidated <- ZIO + .fromEither(ProjectShortcode.from(knoraProject.shortcode.value)) + .mapError(msg => new IllegalArgumentException(s"Invalid shortcode: $msg")) + shortnameValidated <- ZIO + .fromEither(ProjectShortname.from(knoraProject.shortname.value)) + .mapError(msg => new IllegalArgumentException(s"Invalid shortname: $msg")) + projectInfo = ProjectInfo( + shortcode = shortcodeValidated, + shortname = shortnameValidated, + iri = projectIri, + fullName = knoraProject.longname.map(_.value), + description = descriptions, + status = knoraProject.status.value, + lists = lists, + ontologies = ontologies, + ) + } yield projectInfo + + private def convertMultiLangDescriptions( + descriptions: List[Description], + ): Task[MultilingualText] = + val descriptionsMap = buildLanguageMapFromDescriptions(descriptions) + ZIO + .fromEither(MultilingualText.from(descriptionsMap)) + .mapError(msg => new IllegalArgumentException(s"Invalid multilingual descriptions: $msg")) + + private def buildLanguageMapFromDescriptions(descriptions: List[Description]): Map[String, String] = + descriptions.map { desc => + val lang = desc.value.language.getOrElse("en") + lang -> desc.value.value + }.toMap + + private def createOntologyFromReadOntologyV2( + readOntologyV2: ReadOntologyV2, + ): Task[Ontology] = + for { + ontologyIri <- ZIO + .fromEither(OntologyIri.from(readOntologyV2.ontologyMetadata.ontologyIri.toString)) + .mapError(msg => new IllegalArgumentException(s"Invalid ontology IRI: $msg")) + // BR: Ontology label is required - all ontologies must have a meaningful label + ontologyLabel <- ZIO + .fromOption(readOntologyV2.ontologyMetadata.label) + .orElseFail(new IllegalStateException(s"Ontology ${ontologyIri.value} has no label defined")) + ontology = Ontology( + iri = ontologyIri, + label = ontologyLabel, + ) + } yield ontology + + private def createListPreviewFromADM( + listInfo: ListRootNodeInfoADM, + ): Task[ListPreview] = + for { + listIri <- ZIO + .fromEither(ListIri.from(listInfo.id)) + .mapError(msg => new IllegalArgumentException(s"Invalid list IRI: $msg")) + labelsMap = ProjectsService.convertStringLiteralSequenceToMap(listInfo.labels) + labels <- ZIO + .fromEither(MultilingualText.from(labelsMap)) + .mapError(msg => new IllegalArgumentException(s"Invalid multilingual labels: $msg")) + listPreview = ListPreview( + iri = listIri, + labels = labels, + ) + } yield listPreview +} + +object ProjectsService { + val layer = ZLayer.derive[ProjectsService] + + def convertStringLiteralSequenceToMap( + literals: StringLiteralSequenceV2, + ): Map[String, String] = + buildLanguageMapFromStringLiterals(literals.stringLiterals) + + private def buildLanguageMapFromStringLiterals( + stringLiterals: Seq[StringLiteralV2], + ): Map[String, String] = + stringLiterals.map { literal => + val lang = literal.language.getOrElse("en") + lang -> literal.value + }.toMap +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/ProjectsRepoModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/ProjectsRepoModule.scala new file mode 100644 index 00000000000..d8c71a078ea --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/ProjectsRepoModule.scala @@ -0,0 +1,33 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.repo + +import zio.URLayer + +import org.knora.webapi.responders.admin.ListsResponder +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.lists.domain.ListsService +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.v3.projects.domain.model.ProjectsRepo +import org.knora.webapi.slice.v3.projects.repo.service.ProjectsRepoDb +import org.knora.webapi.slice.v3.projects.repo.service.ProjectsRepoLive +import org.knora.webapi.store.triplestore.api.TriplestoreService + +object ProjectsRepoModule { self => + type Dependencies = + // format: off + KnoraProjectService & + ListsResponder & + ListsService & + OntologyRepo & + TriplestoreService + // format: on + + type Provided = ProjectsRepo + + val layer: URLayer[self.Dependencies, self.Provided] = + ProjectsRepoDb.layer >>> ProjectsRepoLive.layer +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/query/ProjectsQueryBuilder.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/query/ProjectsQueryBuilder.scala new file mode 100644 index 00000000000..4b9ef7ba01e --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/query/ProjectsQueryBuilder.scala @@ -0,0 +1,85 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.repo.query + +import org.eclipse.rdf4j.model.vocabulary.OWL +import org.eclipse.rdf4j.model.vocabulary.RDF +import org.eclipse.rdf4j.model.vocabulary.RDFS +import org.eclipse.rdf4j.sparqlbuilder.constraint.Expressions +import org.eclipse.rdf4j.sparqlbuilder.constraint.SparqlFunction +import org.eclipse.rdf4j.sparqlbuilder.core.SparqlBuilder.`var` as variable +import org.eclipse.rdf4j.sparqlbuilder.core.SparqlBuilder.prefix +import org.eclipse.rdf4j.sparqlbuilder.core.query.* +import org.eclipse.rdf4j.sparqlbuilder.rdf.Rdf + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.OntologyIri +import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select + +object ProjectsQueryBuilder { + + def buildInstanceCountQuery( + shortcode: Shortcode, + shortname: Shortname, + classIris: List[String], + ): Select = { + val dataGraphIri = Rdf.iri(s"http://www.knora.org/data/${shortcode.value}/${shortname.value}") + val knoraBasePrefix = prefix("knora-base", Rdf.iri("http://www.knora.org/ontology/knora-base#")) + val isDeletedProp = knoraBasePrefix.iri("isDeleted") + + val classVar = variable("class") + val resourceVar = variable("resource") + val countVar = variable("count") + + val graphPattern = resourceVar + .has(RDF.TYPE, classVar) + .filterNotExists(resourceVar.has(isDeletedProp, Rdf.literalOf(true))) + .from(dataGraphIri) + + // Create VALUES clause using RDF4J IRI objects for proper escaping + val classIriObjects = classIris.map(Rdf.iri(_)) + val classValues = classIriObjects.map(_.getQueryString).mkString(" ") + + val baseQuery = Queries + .SELECT(classVar, Expressions.count(resourceVar).as(countVar)) + .where(graphPattern) + .groupBy(classVar) + .prefix(RDF.NS) + .prefix(knoraBasePrefix) + + // Manually insert VALUES clause into the query string since RDF4J doesn't support it directly + val queryString = baseQuery.getQueryString + val valuesClause = s"VALUES ${classVar.getQueryString} { $classValues }" + val modifiedQueryString = queryString.replaceFirst("WHERE \\{", s"WHERE { $valuesClause ") + + Select(modifiedQueryString) + } + + def buildClassesQuery(ontologyIri: OntologyIri): Select = { + val ontologyGraph = Rdf.iri(ontologyIri.value) + + val classVar = variable("class") + val labelVar = variable("label") + val langVar = variable("lang") + + val graphPattern = classVar + .isA(OWL.CLASS) + .andHas(RDFS.LABEL, labelVar) + .from(ontologyGraph) + + val bindExpression = Expressions.bind(Expressions.function(SparqlFunction.LANG, labelVar), langVar) + + val query = Queries + .SELECT(classVar, labelVar, langVar) + .where(graphPattern, bindExpression) + .prefix(RDF.NS) + .prefix(RDFS.NS) + .prefix(OWL.NS) + + Select(query.getQueryString) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoDb.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoDb.scala new file mode 100644 index 00000000000..133cc898fe0 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoDb.scala @@ -0,0 +1,102 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.repo.service + +import zio.* + +import org.knora.webapi.messages.admin.responder.listsmessages.ListsGetResponseADM +import org.knora.webapi.messages.util.rdf.SparqlSelectResult +import org.knora.webapi.messages.util.rdf.VariableResultsRow +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyV2 +import org.knora.webapi.responders.admin.ListsResponder +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.lists.domain.ListsService +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* +import org.knora.webapi.slice.v3.projects.domain.model.ProjectsRepo +import org.knora.webapi.slice.v3.projects.repo.query.ProjectsQueryBuilder +import org.knora.webapi.store.triplestore.api.TriplestoreService + +private[repo] final case class ProjectsRepoDb( + knoraProjectService: KnoraProjectService, + listsService: ListsService, + listsResponder: ListsResponder, + ontologyRepo: OntologyRepo, + triplestore: TriplestoreService, +) extends ProjectsRepo { + + override def findProjectByIri(id: ProjectIri): Task[Option[KnoraProject]] = + knoraProjectService.findById(id) + + override def findProjectByShortcode(shortcode: Shortcode): Task[Option[KnoraProject]] = + knoraProjectService.findByShortcode(shortcode) + + override def findOntologiesByProject(projectId: ProjectIri): Task[List[ReadOntologyV2]] = + ontologyRepo.findByProject(projectId) + + override def findListsByProject(projectId: ProjectIri): Task[ListsGetResponseADM] = + listsResponder.getLists(Some(Left(projectId))) + + override def countInstancesByClasses( + shortcode: Shortcode, + shortname: Shortname, + classIris: List[String], + ): Task[Map[String, Int]] = + if (classIris.isEmpty) { + ZIO.succeed(Map.empty) + } else { + val query = ProjectsQueryBuilder.buildInstanceCountQuery(shortcode, shortname, classIris) + triplestore + .query(query) + .map(result => buildClassCountMap(classIris, result)) + .mapError(ex => + new RuntimeException( + s"Failed to count instances for classes in project ${shortcode.value}/${shortname.value}", + ex, + ), + ) + } + + override def getClassesFromOntology(ontologyIri: OntologyIri): Task[List[(String, Map[String, String])]] = { + val query = ProjectsQueryBuilder.buildClassesQuery(ontologyIri) + triplestore + .query(query) + .map(processClassQueryResults) + .mapError(ex => new RuntimeException(s"Failed to get classes from ontology ${ontologyIri.value}", ex)) + } + + private def processClassQueryResults( + result: SparqlSelectResult, + ): List[(String, Map[String, String])] = + result.results.bindings + .groupBy(row => row.rowMap("class")) + .map { case (classIri, rows) => + classIri -> rows.flatMap(extractLabelAndLanguage).toMap + } + .toList + + private def buildClassCountMap(classIris: List[String], result: SparqlSelectResult): Map[String, Int] = + classIris.map(_ -> 0).toMap ++ result.results.bindings.map { row => + val classIri = row.rowMap("class") + val count = row.rowMap.get("count").fold(0)(_.toInt) + classIri -> count + }.toMap + + private def extractLabelAndLanguage(row: VariableResultsRow): Option[(String, String)] = + for { + label <- row.rowMap.get("label") + lang <- row.rowMap.get("lang") + if lang.nonEmpty + } yield lang -> label +} + +private[repo] object ProjectsRepoDb { + val layer = ZLayer.derive[ProjectsRepoDb] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoLive.scala new file mode 100644 index 00000000000..2916ba2d5fa --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/v3/projects/repo/service/ProjectsRepoLive.scala @@ -0,0 +1,156 @@ +/* + * Copyright © 2021 - 2025 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.v3.projects.repo.service + +import zio.* +import zio.cache.Cache +import zio.cache.Lookup + +import org.knora.webapi.messages.admin.responder.listsmessages.ListsGetResponseADM +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyV2 +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.v3.projects.domain.model.DomainTypes.* +import org.knora.webapi.slice.v3.projects.domain.model.ProjectsRepo + +private final case class InstanceCountCacheKey( + shortcode: Shortcode, + shortname: Shortname, + classIris: List[String], +) + +final case class ProjectsRepoLive( + underlying: ProjectsRepo, + instanceCountsCache: Cache[InstanceCountCacheKey, Throwable, Map[String, Int]], + ontologyClassesCache: Cache[OntologyIri, Throwable, List[(String, Map[String, String])]], + projectByIdCache: Cache[ProjectIri, Throwable, Option[KnoraProject]], + projectByShortcodeCache: Cache[Shortcode, Throwable, Option[KnoraProject]], + ontologiesByProjectCache: Cache[ProjectIri, Throwable, List[ReadOntologyV2]], + listsByProjectCache: Cache[ProjectIri, Throwable, ListsGetResponseADM], +) extends ProjectsRepo { + + override def findProjectByIri(id: ProjectIri): Task[Option[KnoraProject]] = + projectByIdCache + .get(id) + .tapError(error => ZIO.logWarning(s"V3 Projects Cache: Error in findProjectById cache for ${id.value}: $error")) + + override def findProjectByShortcode(shortcode: Shortcode): Task[Option[KnoraProject]] = + projectByShortcodeCache + .get(shortcode) + .tapError(error => + ZIO.logWarning(s"V3 Projects Cache: Error in findProjectByShortcode cache for ${shortcode.value}: $error"), + ) + + override def findOntologiesByProject(projectId: ProjectIri): Task[List[ReadOntologyV2]] = + ontologiesByProjectCache + .get(projectId) + .tapError(error => + ZIO.logWarning(s"V3 Projects Cache: Error in findOntologiesByProject cache for ${projectId.value}: $error"), + ) + + override def findListsByProject(projectId: ProjectIri): Task[ListsGetResponseADM] = + listsByProjectCache + .get(projectId) + .tapError(error => + ZIO.logWarning(s"V3 Projects Cache: Error in findListsByProject cache for ${projectId.value}: $error"), + ) + + override def countInstancesByClasses( + shortcode: Shortcode, + shortname: Shortname, + classIris: List[String], + ): Task[Map[String, Int]] = + if (classIris.isEmpty) { + ZIO.succeed(Map.empty) + } else { + val cacheKey = InstanceCountCacheKey(shortcode, shortname, classIris.sorted) + instanceCountsCache + .get(cacheKey) + .tapError(error => + ZIO.logWarning( + s"V3 Projects Cache: Error in countInstancesByClasses cache for ${shortcode.value}/${shortname.value}: $error", + ), + ) + } + + override def getClassesFromOntology(ontologyIri: OntologyIri): Task[List[(String, Map[String, String])]] = + ontologyClassesCache.get(ontologyIri) + +} + +object ProjectsRepoLive { + val layer: ZLayer[ProjectsRepoDb, Nothing, ProjectsRepoLive] = + ZLayer.fromZIO { + for { + underlying <- ZIO.service[ProjectsRepoDb] + + instanceCountsCache <- + Cache.make( + capacity = 1000, + timeToLive = 15.minutes, + lookup = Lookup { (key: InstanceCountCacheKey) => + underlying.countInstancesByClasses(key.shortcode, key.shortname, key.classIris) + }, + ) + + ontologyClassesCache <- + Cache.make( + capacity = 500, + timeToLive = 1.hour, + lookup = Lookup { (ontologyIri: OntologyIri) => + underlying.getClassesFromOntology(ontologyIri) + }, + ) + + projectByIdCache <- + Cache.make( + capacity = 200, + timeToLive = 5.minutes, + lookup = Lookup { (id: ProjectIri) => + underlying.findProjectByIri(id) + }, + ) + + projectByShortcodeCache <- + Cache.make( + capacity = 200, + timeToLive = 5.minutes, + lookup = Lookup { (shortcode: Shortcode) => + underlying.findProjectByShortcode(shortcode) + }, + ) + + ontologiesByProjectCache <- + Cache.make( + capacity = 200, + timeToLive = 5.minutes, + lookup = Lookup { (projectId: ProjectIri) => + underlying.findOntologiesByProject(projectId) + }, + ) + + listsByProjectCache <- + Cache.make( + capacity = 200, + timeToLive = 5.minutes, + lookup = Lookup { (projectId: ProjectIri) => + underlying.findListsByProject(projectId) + }, + ) + + } yield ProjectsRepoLive( + underlying, + instanceCountsCache, + ontologyClassesCache, + projectByIdCache, + projectByShortcodeCache, + ontologiesByProjectCache, + listsByProjectCache, + ) + } +}