diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index d8914f9d4bb..f5d0f0b4392 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,7 +2,7 @@ name: CI-build-and-test on: pull_request: - types: [ opened, reopened, synchronize ] + types: [opened, reopened, synchronize] branches-ignore: - "**/graphite-base/**" @@ -73,7 +73,7 @@ jobs: test-e2e: name: Build and E2E test - runs-on: buildjet-4vcpu-ubuntu-2204 + runs-on: buildjet-32vcpu-ubuntu-2204 concurrency: group: ${{ github.ref }}-e2e cancel-in-progress: true @@ -83,7 +83,7 @@ jobs: with: java-version: ${{ env.JAVA_VERSION }} - name: Run end-to-end tests - run: make test-e2e + run: ./sbtx "test-e2e/test" - name: WebApi E2E Test Report uses: dorny/test-reporter@v1 if: success() || failure() @@ -91,15 +91,6 @@ jobs: name: WebApi E2E Test Results path: ./modules/test-e2e/target/test-reports/TEST-*.xml reporter: java-junit - - name: Upload coverage data to codacy - uses: codacy/codacy-coverage-reporter-action@v1 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: ./target/scala-3.3.6/coverage-report/cobertura.xml - - name: Upload coverage data to codecov - uses: codecov/codecov-action@v3 - with: - files: ./target/scala-3.3.6/coverage-report/cobertura.xml test-ingest-integration: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index eb7f0e2ecf6..afaf738dc70 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,8 @@ ingest/docs/openapi/*.yml # Claude Code .claude/settings.local.json /.claude/tmp +.claude-flow/* +.swarm/* # ingest localdev/storage/ diff --git a/AGENTS.md b/AGENTS.md index 25e5abfdaf0..2699f9b29cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ When context is insufficient, prefer asking 1–3 focused questions rather than ## Project Overview DSP-API is the Digital Humanities Service Platform API — a Scala-based REST API for managing semantic data and digital -assets in the humanities. The project uses ZIO for functional programming, Pekko HTTP (Apache Pekko) for the API, and +assets in the humanities. The project uses ZIO for functional programming, zio-http and tapir for the API, and integrates with Apache Jena Fuseki triplestore and the Sipi media server. ## Build System & Commands @@ -74,7 +74,7 @@ Essential commands: - Technology - Language: Scala 3.3.x - FP: ZIO 2.x - - HTTP: Pekko HTTP + Tapir + - HTTP: ZIO HTTP + Tapir - Store: Apache Jena Fuseki (RDF) - Media: Sipi - JSON: ZIO JSON @@ -98,8 +98,7 @@ Essential commands: ## API Structure - Endpoints: defined via Tapir in `*Endpoints.scala` -- Handlers: in `*EndpointsHandler.scala` -- Routes: in `*Routes.scala` and wired through `ApiRoutes.scala` +- ServerEndpoints: in `*ServerEndpoints.scala` - API areas: Admin API, API v2 (main), Management (health/metrics) - Auth: JWT; scoped authorization; session management @@ -107,8 +106,8 @@ Essential commands: - Add a new endpoint 1. Define endpoint in the appropriate `*Endpoints.scala` - 2. Implement handler in `*EndpointsHandler.scala` - 3. Add route in `*Routes.scala` and register in `ApiRoutes.scala` + 2. Connect endpoint definition with server logic in `*ServerEndpoints.scala` + 3. Register in `CompleteApiServerEndpoints.scala` 4. Add unit/integration tests mirroring the main structure - Code style and patterns - Use Scalafmt; prefer ZIO effects over side effects diff --git a/CLAUDE.md b/CLAUDE.md index a9fbcf98952..f134a69df41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview DSP-API is the Digital Humanities Service Platform API - a Scala-based REST API for managing semantic data and digital assets in the humanities. -The project uses ZIO for functional programming, Pekko HTTP (Apache Pekko) for the API, and integrates with Apache Jena Fuseki triplestore and Sipi media server. +The project uses ZIO for functional programming, zio-http as HTTP server and tapir for defining endpoints of the API, and integrates with Apache Jena Fuseki triplestore and Sipi media server. ## Build System & Commands @@ -143,10 +143,10 @@ Each slice typically contains: ## Common Development Tasks ### Adding New Endpoints -1. Define endpoint in appropriate `*Endpoints.scala` -2. Implement handler in `*EndpointsHandler.scala` -3. Add route in `*Routes.scala` -4. Add to main router in `ApiRoutes.scala` +1. Define endpoint in the appropriate `*Endpoints.scala` +2. Connect endpoint definition with server logic in `*ServerEndpoints.scala` +3. Register in `CompleteApiServerEndpoints.scala` +4. Add unit/integration tests mirroring the main structure ### Code Style - Use Scalafmt for formatting @@ -164,4 +164,4 @@ Each slice typically contains: ### Debugging - Use `make stack-logs` to view all service logs - Check `make stack-health` for API health status -- Use `make stack-status` to see container status \ No newline at end of file +- Use `make stack-status` to see container status diff --git a/build.sbt b/build.sbt index aa1eb17100d..30e68f487b3 100644 --- a/build.sbt +++ b/build.sbt @@ -285,6 +285,7 @@ lazy val testkit: Project = Project(id = "testkit", base = file("modules/testkit Dependencies.zioTest, Dependencies.testcontainers, Dependencies.wiremock, + Dependencies.dataFaker, ), publish / skip := true, name := "testkit", @@ -310,7 +311,6 @@ lazy val it: Project = Project(id = "test-it", base = file("modules/test-it")) Test / fork := true, Test / testForkedParallel := false, Test / parallelExecution := false, - Test / javaOptions += "-Dkey=" + sys.props.getOrElse("key", "pekko"), Test / testOptions += Tests.Argument("-oDF"), // full stack traces and durations Test / baseDirectory := (ThisBuild / baseDirectory).value, libraryDependencies ++= Dependencies.webapiDependencies ++ Dependencies.webapiTestDependencies ++ Dependencies.integrationTestDependencies, @@ -342,7 +342,6 @@ lazy val e2e: Project = Project(id = "test-e2e", base = file("modules/test-e2e") Test / fork := true, Test / testForkedParallel := false, Test / parallelExecution := false, - Test / javaOptions += "-Dkey=" + sys.props.getOrElse("key", "pekko"), Test / testOptions += Tests.Argument("-oDF"), Test / baseDirectory := (ThisBuild / baseDirectory).value, libraryDependencies ++= Dependencies.webapiDependencies ++ Dependencies.webapiTestDependencies ++ Dependencies.integrationTestDependencies, diff --git a/docs/03-endpoints/api-util/version.md b/docs/03-endpoints/api-util/version.md index ffe6bc86652..009795453d4 100644 --- a/docs/03-endpoints/api-util/version.md +++ b/docs/03-endpoints/api-util/version.md @@ -19,7 +19,6 @@ Server: webapi/v30.9.0 "buildCommit": "bbb0e65c7", "buildTime": "2024-03-11T17:40:17.322491Z", "fuseki": "2.1.5", - "pekkoHttp": "1.0.1", "scala": "2.13.13", "sipi": "3.9.0", "webapi": "v30.9.0" diff --git a/docs/04-publishing-deployment/configuration.md b/docs/04-publishing-deployment/configuration.md index b5b10aa1262..4eeed0bcec4 100644 --- a/docs/04-publishing-deployment/configuration.md +++ b/docs/04-publishing-deployment/configuration.md @@ -1,14 +1,12 @@ # Configuration -All configuration for Knora is done in `application.conf`. Besides the Knora application -specific configuration, there we can also find configuration for the underlying Pekko library. +All configuration for Knora is done in `application.conf`. For optimal performance it is important to tune the configuration to the hardware used, mainly to the number of CPUs and cores per CPU. The relevant sections for tuning are: -- `pekko.actor.deployment` - `knora-actor-dispatcher` - `knora-blocking-dispatcher` @@ -18,9 +16,6 @@ A number of core settings is additionally configurable through system environmen | key in application.conf | environment variable | default value | |----------------------------------------|-------------------------------------------------|-------------------------| -| pekko.log-config-on-start | KNORA_AKKA_LOG_CONFIG_ON_START | off | -| pekko.loglevel | KNORA_AKKA_LOGLEVEL | INFO | -| pekko.stdout-loglevel | KNORA_AKKA_STDOUT_LOGLEVEL | INFO | | app.print-extended-config | KNORA_WEBAPI_PRINT_EXTENDED_CONFIG | false | | app.bcrypt-password-strength | KNORA_WEBAPI_BCRYPT_PASSWORD_STRENGTH | 12 | | app.jwt.secret | KNORA_WEBAPI_JWT_SECRET_KEY | super-secret-key | diff --git a/docs/05-internals/design/api-v2/how-to-add-a-route.md b/docs/05-internals/design/api-v2/how-to-add-a-route.md deleted file mode 100644 index 3c7d4396f5a..00000000000 --- a/docs/05-internals/design/api-v2/how-to-add-a-route.md +++ /dev/null @@ -1,47 +0,0 @@ -# How to Add an API v2 Route - -## Write SPARQL templates - -Add any SPARQL templates you need to `src/main/twirl/queries/sparql/v2`, -using the [Twirl](https://github.com/playframework/twirl) template -engine. - -## Write Responder Request and Response Messages - -Add a file to the `org.knora.webapi.messages.v2.responder` -package, containing case classes for your responder's request and -response messages. Add a trait that the responder's request messages -extend. Each request message type should contain a `UserADM`. - -Request and response messages should be designed following the patterns described -in [JSON-LD Parsing and Formatting](json-ld.md). Each responder's -request messages should extend a responder-specific trait, so that -`ResponderManager` will know which responder to route those messages to. - -## Write a Responder - -Write a Pekko actor class that extends `org.knora.webapi.responders.Responder`, -and add it to the `org.knora.webapi.responders.v2` package. - -Give your responder a `receive(msg: YourCustomType)` method that handles each of your -request message types by generating a `Future` containing a response message. - -Add the path of your responder to the `org.knora.webapi.responders` package object, -and add code to `ResponderManager` to instantiate the new responder. Then add a `case` to -the `receive` method in `ResponderManager`, to match messages that extend your request -message trait, and pass them to that responder's receive method. -The responder's resulting `Future` must be passed to the `ActorUtil.future2Message`. -See [Error Handling](../principles/design-overview.md#error-handling) for details. - -## Write a Route - -Add a class to the `org.knora.webapi.routing.v2` package for your -route, using the Pekko HTTP [Routing DSL](https://pekko.apache.org/docs/pekko-http/current/routing-dsl/index.html). -See the routes in that package for examples. Typically, each route -route will construct a responder request message and pass it to -`RouteUtilV2.runRdfRouteWithFuture` to handle the request. - -Finally, add your route's `knoraApiPath` function to the `apiRoutes` member -variable in `KnoraService`. Any exception thrown inside the route will -be handled by the `KnoraExceptionHandler`, so that the correct client -response (including the HTTP status code) will be returned. diff --git a/ingest/src/main/scala/swiss/dasch/api/MaintenanceEndpointsHandler.scala b/ingest/src/main/scala/swiss/dasch/api/MaintenanceEndpointsHandler.scala index 43170faa42f..19f4f68f91a 100644 --- a/ingest/src/main/scala/swiss/dasch/api/MaintenanceEndpointsHandler.scala +++ b/ingest/src/main/scala/swiss/dasch/api/MaintenanceEndpointsHandler.scala @@ -68,12 +68,11 @@ final case class MaintenanceEndpointsHandler( .as("work in progress"), ) - val endpoints: List[ZServerEndpoint[Any, Any]] = - List( - postMaintenanceEndpoint, - needsTopLeftCorrectionEndpoint, - wasTopLeftCorrectionAppliedEndpoint, - ) + val endpoints: List[ZServerEndpoint[Any, Any]] = List( + postMaintenanceEndpoint, + needsTopLeftCorrectionEndpoint, + wasTopLeftCorrectionAppliedEndpoint, + ) } object MaintenanceEndpointsHandler { diff --git a/mkdocs.yml b/mkdocs.yml index 200ac1310cd..5f6f025633c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,7 +74,6 @@ nav: - Ontology Schemas: 05-internals/design/api-v2/ontology-schemas.md - Smart IRIs: 05-internals/design/api-v2/smart-iris.md - Content Wrappers: 05-internals/design/api-v2/content-wrappers.md - - How to Add an API v2 Route: 05-internals/design/api-v2/how-to-add-a-route.md - JSON-LD Parsing and Formatting: 05-internals/design/api-v2/json-ld.md - Ontology Management: 05-internals/design/api-v2/ontology-management.md - DSP-API and Sipi: 05-internals/design/api-v2/sipi.md diff --git a/modules/test-e2e/src/test/resources/application.conf b/modules/test-e2e/src/test/resources/application.conf index e3ba0c82aeb..c707248e0c1 100644 --- a/modules/test-e2e/src/test/resources/application.conf +++ b/modules/test-e2e/src/test/resources/application.conf @@ -1,5 +1,3 @@ -include "test" - app { triplestore { dbtype = "fuseki" diff --git a/modules/test-e2e/src/test/resources/test.conf b/modules/test-e2e/src/test/resources/test.conf deleted file mode 100644 index b8aecaf3877..00000000000 --- a/modules/test-e2e/src/test/resources/test.conf +++ /dev/null @@ -1,29 +0,0 @@ -akka { - log-config-on-start = false - loggers = ["akka.event.slf4j.Slf4jLogger"] - logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" - loglevel = "ERROR" - stdout-loglevel = "ERROR" - log-dead-letters = off - log-dead-letters-during-shutdown = off - - actor { - default-dispatcher { - executor = "fork-join-executor" - fork-join-executor { - parallelism-min = 8 - parallelism-factor = 2.0 - parallelism-max = 8 - } - } - } - - http.host-connection-pool.response-entity-subscription-timeout = 10 seconds - - # The time period within which the TCP binding process must be completed. - http.server.bind-timeout = 15 seconds -} - -app { - allow-reload-over-http = true -} diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/CORSSupportE2ESpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/CORSSupportE2ESpec.scala index 16acdcc1686..57b729ccb99 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/CORSSupportE2ESpec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/CORSSupportE2ESpec.scala @@ -49,7 +49,7 @@ object CORSSupportE2ESpec extends E2EZSpec { corsClient(_.options(uri"/admin/projects", Header(AccessControlRequestMethod, "GET"))) .map(response => assertTrue( - response.code == StatusCode.Ok, + response.code.isSuccess, response.header(AccessControlAllowOrigin).contains("http://example.com"), response.header(AccessControlAllowMethods).exists(_.contains("GET")), response.header(AccessControlAllowMethods).exists(_.contains("PUT")), @@ -62,14 +62,13 @@ object CORSSupportE2ESpec extends E2EZSpec { response.header(AccessControlAllowCredentials).contains("true"), ), ) - }, test("send `Access-Control-Allow-Origin` header when the Knora resource is found ") { val resourceIri = "http://rdfh.ch/0001/55UrkgTKR2SEQgnsLWI9mg" corsClient(_.get(uri"/v2/resources/$resourceIri")) .map(response => assertTrue( - response.code == StatusCode.Ok, + response.code.isSuccess, response.header(AccessControlAllowOrigin).contains("http://example.com"), ), ) diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/OntologyFormatsE2ESpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/OntologyFormatsE2ESpec.scala index a08134e30e6..d439e884646 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/OntologyFormatsE2ESpec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/OntologyFormatsE2ESpec.scala @@ -20,8 +20,10 @@ import org.knora.webapi.* import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Simple import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject +import org.knora.webapi.messages.util.rdf.JsonLD import org.knora.webapi.messages.util.rdf.RdfFormatUtil import org.knora.webapi.messages.util.rdf.RdfModel +import org.knora.webapi.messages.util.rdf.RdfXml import org.knora.webapi.messages.util.rdf.Turtle import org.knora.webapi.sharedtestdata.SharedOntologyTestDataADM import org.knora.webapi.sharedtestdata.SharedTestDataADM @@ -78,15 +80,11 @@ object OntologyFormatsE2ESpec extends E2EZSpec { } } - private val mediaTypeJsonLd: MediaType = MediaType.unsafeParse("application/ld+json") - private val mediaTypeTurtle: MediaType = MediaType.unsafeParse("text/turtle") - private val mediaTypeRdfXml: MediaType = MediaType.unsafeParse("application/rdf+xml") - private def checkTestCase(httpGetTest: HttpGetTest) = for { - responseJsonLd <- getResponse(httpGetTest.uri, mediaTypeJsonLd) - responseTtl <- getResponse(httpGetTest.uri, mediaTypeTurtle) - responseRdfXml <- getResponse(httpGetTest.uri, mediaTypeRdfXml) + responseJsonLd <- getResponse(httpGetTest.uri, JsonLD.mediaType) + responseTtl <- getResponse(httpGetTest.uri, Turtle.mediaType) + responseRdfXml <- getResponse(httpGetTest.uri, RdfXml.mediaType) _ = if (!httpGetTest.fileExists) { httpGetTest.writeReceived(responseJsonLd) throw new AssertionError(s"File not found ${httpGetTest.makeFile().toAbsolutePath}") @@ -229,9 +227,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { for { allEntities <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Simple.KnoraApiOntologyIri}", - mediaTypeJsonLd, + JsonLD.mediaType, ) - knoraApi <- getResponse(uri"/ontology/knora-api/simple/v2", mediaTypeJsonLd) + knoraApi <- getResponse(uri"/ontology/knora-api/simple/v2", JsonLD.mediaType) } yield assertTrue( RdfModel.fromJsonLD(allEntities) == RdfModel.fromJsonLD(knoraApi), ) @@ -241,9 +239,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseTurtle <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Simple.KnoraApiOntologyIri}", - mediaTypeTurtle, + Turtle.mediaType, ) - knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/simple/v2", mediaTypeTurtle) + knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/simple/v2", Turtle.mediaType) } yield assertTrue( RdfModel.fromTurtle(ontologyAllEntitiesResponseTurtle) == RdfModel.fromTurtle(knoraApiResponseTurtle), ) @@ -253,9 +251,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseRdfXml <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Simple.KnoraApiOntologyIri}", - mediaTypeRdfXml, + RdfXml.mediaType, ) - knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/simple/v2", mediaTypeRdfXml) + knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/simple/v2", RdfXml.mediaType) } yield assertTrue( RdfModel.fromRdfXml(ontologyAllEntitiesResponseRdfXml) == RdfModel.fromRdfXml(knoraApiResponseRdfXml), ) @@ -263,8 +261,8 @@ object OntologyFormatsE2ESpec extends E2EZSpec { test("serve the knora-api in the complex schema on two separate endpoints JSON-LD") { for { ontologyAllEntitiesResponseJson <- - getResponse(uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", mediaTypeJsonLd) - knoraApiResponseJson <- getResponse(uri"/ontology/knora-api/v2", mediaTypeJsonLd) + getResponse(uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", JsonLD.mediaType) + knoraApiResponseJson <- getResponse(uri"/ontology/knora-api/v2", JsonLD.mediaType) } yield assertTrue( RdfModel.fromJsonLD(ontologyAllEntitiesResponseJson) == RdfModel.fromJsonLD(knoraApiResponseJson), ) @@ -274,9 +272,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseTurtle <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", - mediaTypeTurtle, + Turtle.mediaType, ) - knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/v2", mediaTypeTurtle) + knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/v2", Turtle.mediaType) } yield assertTrue( RdfModel.fromTurtle(ontologyAllEntitiesResponseTurtle) == RdfModel.fromTurtle(knoraApiResponseTurtle), ) @@ -286,9 +284,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseRdfXml <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", - mediaTypeRdfXml, + RdfXml.mediaType, ) - knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/v2", mediaTypeRdfXml) + knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/v2", RdfXml.mediaType) } yield assertTrue( RdfModel.fromRdfXml(ontologyAllEntitiesResponseRdfXml) == RdfModel.fromRdfXml(knoraApiResponseRdfXml), ) @@ -298,9 +296,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseTurtle <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", - mediaTypeTurtle, + Turtle.mediaType, ) - knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/v2", mediaTypeTurtle) + knoraApiResponseTurtle <- getResponse(uri"/ontology/knora-api/v2", Turtle.mediaType) } yield assertTrue( RdfModel.fromTurtle(ontologyAllEntitiesResponseTurtle) == RdfModel.fromTurtle(knoraApiResponseTurtle), ) @@ -310,9 +308,9 @@ object OntologyFormatsE2ESpec extends E2EZSpec { ontologyAllEntitiesResponseRdfXml <- getResponse( uri"/v2/ontologies/allentities/${KnoraApiV2Complex.KnoraApiOntologyIri}", - mediaTypeRdfXml, + RdfXml.mediaType, ) - knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/v2", mediaTypeRdfXml) + knoraApiResponseRdfXml <- getResponse(uri"/ontology/knora-api/v2", RdfXml.mediaType) } yield assertTrue( RdfModel.fromRdfXml(ontologyAllEntitiesResponseRdfXml) == RdfModel.fromRdfXml(knoraApiResponseRdfXml), ) diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ResponseCheckerV2Spec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ResponseCheckerV2Spec.scala index efbdb341fdb..ec95071ea76 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ResponseCheckerV2Spec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ResponseCheckerV2Spec.scala @@ -5,44 +5,47 @@ package org.knora.webapi.e2e.v2 -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec +import zio.* +import zio.test.* +import zio.test.Assertion.* import java.nio.file.Paths +import org.knora.webapi.E2EZSpec import org.knora.webapi.util.FileUtil -/** - * Tests [[ResponseCheckerV2]]. - */ -class ResponseCheckerV2Spec extends AnyWordSpec with Matchers { +object ResponseCheckerV2Spec extends E2EZSpec { - "ResponseCheckerV2" should { - "not throw an exception if received and expected resource responses are the same" in { + override val e2eSpec = suite("ResponseCheckerV2")( + test("not throw an exception if received and expected resource responses are the same") { val expectedAnswerJSONLD = - FileUtil.readTextFile( - Paths.get("test_data/generated_test_data/resourcesR2RV2/ThingWithLinkComplex.jsonld"), - ) + FileUtil.readTextFile(Paths.get("test_data/generated_test_data/resourcesR2RV2/ThingWithLinkComplex.jsonld")) - ResponseCheckerV2.compareJSONLDForResourcesResponse( - expectedJSONLD = expectedAnswerJSONLD, - receivedJSONLD = expectedAnswerJSONLD, - ) - } - - "not throw an exception if received and expected mapping responses are the same" in { + ZIO + .attempt( + ResponseCheckerV2.compareJSONLDForResourcesResponse( + expectedJSONLD = expectedAnswerJSONLD, + receivedJSONLD = expectedAnswerJSONLD, + ), + ) + .as(assertCompletes) + }, + test("not throw an exception if received and expected mapping responses are the same") { val expectedAnswerJSONLD = FileUtil.readTextFile( Paths.get("test_data/generated_test_data/standoffR2RV2/mappingCreationResponse.jsonld"), ) - ResponseCheckerV2.compareJSONLDForMappingCreationResponse( - expectedJSONLD = expectedAnswerJSONLD, - receivedJSONLD = expectedAnswerJSONLD, - ) - } - - "throw an exception if received and expected resource responses are different" in { + ZIO + .attempt( + ResponseCheckerV2.compareJSONLDForMappingCreationResponse( + expectedJSONLD = expectedAnswerJSONLD, + receivedJSONLD = expectedAnswerJSONLD, + ), + ) + .as(assertCompletes) + }, + test("throw an exception if received and expected resource responses are different") { val expectedAnswerJSONLD = FileUtil.readTextFile( Paths.get("test_data/generated_test_data/resourcesR2RV2/ThingWithLinkComplex.jsonld"), @@ -50,15 +53,14 @@ class ResponseCheckerV2Spec extends AnyWordSpec with Matchers { val receivedAnswerJSONLD = FileUtil.readTextFile(Paths.get("test_data/generated_test_data/resourcesR2RV2/ThingWithListValue.jsonld")) - assertThrows[AssertionError] { + ZIO.attempt { ResponseCheckerV2.compareJSONLDForResourcesResponse( expectedJSONLD = expectedAnswerJSONLD, receivedJSONLD = receivedAnswerJSONLD, ) - } - } - - "throw an exception if the values of the received and expected resource responses are different" in { + }.exit.flatMap(exit => assert(exit)(failsWithA[AssertionError])) + }, + test("throw an exception if the values of the received and expected resource responses are different") { val expectedAnswerJSONLD = FileUtil.readTextFile( Paths.get("test_data/generated_test_data/resourcesR2RV2/BookReiseInsHeiligeLand.jsonld"), @@ -68,15 +70,14 @@ class ResponseCheckerV2Spec extends AnyWordSpec with Matchers { Paths.get("test_data/generated_test_data/resourcesR2RV2/BookReiseInsHeiligeLandPreview.jsonld"), ) - assertThrows[AssertionError] { + ZIO.attempt { ResponseCheckerV2.compareJSONLDForResourcesResponse( expectedJSONLD = expectedAnswerJSONLD, receivedJSONLD = receivedAnswerJSONLD, ) - } - } - - "throw an exception if the number of values of the received and expected resource responses are different" in { + }.exit.flatMap(exit => assert(exit)(failsWithA[AssertionError])) + }, + test("throw an exception if the number of values of the received and expected resource responses are different") { val expectedAnswerJSONLD = FileUtil.readTextFile( Paths.get("test_data/generated_test_data/resourcesR2RV2/NarrenschiffFirstPage.jsonld"), @@ -87,15 +88,14 @@ class ResponseCheckerV2Spec extends AnyWordSpec with Matchers { Paths.get("test_data/generated_test_data/responseCheckerR2RV2/NarrenschiffFirstPageWrong.jsonld"), ) - assertThrows[AssertionError] { + ZIO.attempt { ResponseCheckerV2.compareJSONLDForResourcesResponse( expectedJSONLD = expectedAnswerJSONLD, receivedJSONLD = receivedAnswerJSONLD, ) - } - } - - "throw an exception if received and expected mapping responses are different" in { + }.exit.flatMap(exit => assert(exit)(failsWithA[AssertionError])) + }, + test("throw an exception if received and expected mapping responses are different") { val expectedAnswerJSONLD = FileUtil.readTextFile( Paths.get("test_data/generated_test_data/standoffR2RV2/mappingCreationResponse.jsonld"), @@ -107,12 +107,12 @@ class ResponseCheckerV2Spec extends AnyWordSpec with Matchers { ), ) - assertThrows[AssertionError] { + ZIO.attempt { ResponseCheckerV2.compareJSONLDForMappingCreationResponse( expectedJSONLD = expectedAnswerJSONLD, receivedJSONLD = receivedAnswerJSONLD, ) - } - } - } + }.exit.flatMap(exit => assert(exit)(failsWithA[AssertionError])) + }, + ) } diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ontology/InputOntologyV2Spec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ontology/InputOntologyV2Spec.scala index 776ae457525..96f05f3e56a 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ontology/InputOntologyV2Spec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/e2e/v2/ontology/InputOntologyV2Spec.scala @@ -13,9 +13,9 @@ import java.time.Instant import dsp.errors.BadRequestException import org.knora.webapi.ApiV2Complex +import org.knora.webapi.E2EZSpec import org.knora.webapi.e2e.v2.ontology import org.knora.webapi.messages.IriConversions.* -import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.store.triplestoremessages.SmartIriLiteralV2 import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 import org.knora.webapi.messages.util.rdf.JsonLDUtil @@ -26,11 +26,7 @@ import org.knora.webapi.messages.v2.responder.ontologymessages.PredicateInfoV2 import org.knora.webapi.messages.v2.responder.ontologymessages.PropertyInfoContentV2 import org.knora.webapi.slice.ontology.domain.model.Cardinality.ZeroOrOne -/** - * Tests [[InputOntologyV2]]. - */ -object InputOntologyV2Spec extends ZIOSpecDefault { - private implicit val stringFormatter: StringFormatter = StringFormatter.getInitializedTestInstance +object InputOntologyV2Spec extends E2EZSpec { private val PropertyDef = InputOntologyV2( ontologyMetadata = OntologyMetadataV2( @@ -112,9 +108,8 @@ object InputOntologyV2Spec extends ZIOSpecDefault { properties = Map(), ) - val spec = suite("InputOntologyV2")( + override val e2eSpec = suite("InputOntologyV2")( test("parse a property definition") { - val params = """ |{ @@ -213,7 +208,6 @@ object InputOntologyV2Spec extends ZIOSpecDefault { assertTrue(actual == ClassDef) }, test("reject an entity definition with an invalid IRI") { - val params = s""" |{ diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala index f5e2784f8ab..dd7671f4504 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala @@ -39,7 +39,6 @@ import org.knora.webapi.util.MutableTestIri * Tests interaction between Knora and Sipi using Knora API v2. */ object KnoraSipiIntegrationV2ITSpec extends E2EZSpec { - private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance private val stillImageResourceIri = new MutableTestIri private val stillImageFileValueIri = new MutableTestIri diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersEndpointsE2ESpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersEndpointsE2ESpec.scala index b58626f25cc..ad8ad318a46 100644 --- a/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersEndpointsE2ESpec.scala +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersEndpointsE2ESpec.scala @@ -8,23 +8,22 @@ package org.knora.webapi.slice.admin.api import sttp.client4.UriContext import sttp.model.StatusCode import zio.ZIO +import zio.json.ast.Json import zio.test.* import org.knora.webapi.* import org.knora.webapi.messages.util.KnoraSystemInstances import org.knora.webapi.sharedtestdata.SharedTestDataADM import org.knora.webapi.sharedtestdata.SharedTestDataADM.* -import org.knora.webapi.sharedtestdata.SharedTestDataADM2 import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.* -import org.knora.webapi.slice.admin.api.model.Project import org.knora.webapi.slice.admin.api.service.UserRestService.UserResponse +import org.knora.webapi.slice.admin.api.service.UserRestService.UsersResponse import org.knora.webapi.slice.admin.domain.model.* import org.knora.webapi.slice.common.domain.LanguageCode import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LoginPayload import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.TokenResponse import org.knora.webapi.testservices.ResponseOps import org.knora.webapi.testservices.ResponseOps.assert200 -import org.knora.webapi.testservices.TestAdminApiClient import org.knora.webapi.testservices.TestApiClient import org.knora.webapi.util.MutableTestIri @@ -34,7 +33,6 @@ import org.knora.webapi.util.MutableTestIri object AdminUsersEndpointsE2ESpec extends E2EZSpec { private val projectAdminUser = imagesUser01 - private val multiUserIri = UserIri.unsafeFrom(SharedTestDataADM2.multiuserUser.userData.user_id.get) private val customUserIri = UserIri.unsafeFrom("http://rdfh.ch/users/14pxW-LAQIaGcCRiNCPJcQ") private val otherCustomUserIri = UserIri.unsafeFrom("http://rdfh.ch/users/v8_12VcJRlGNFCjYzqJ5cA") @@ -44,40 +42,40 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { val e2eSpec = suite("The Users Route ('admin/users')")( suite("used to query user information [FUNCTIONALITY]")( test("return all users") { - TestAdminApiClient - .getAllUsers(rootUser) + TestApiClient + .getJson[UsersResponse](uri"/admin/users", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return a single user profile identified by iri") { - TestAdminApiClient - .getUser(rootUser.userIri, rootUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/${rootUser.id}", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return a single user profile identified by email") { - TestAdminApiClient - .getUserByEmail(rootUser.getEmail, rootUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/email/${rootUser.email}", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return a single user profile identified by username") { - TestAdminApiClient - .getUserByUsername(rootUser.getUsername, rootUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/username/${rootUser.username}", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, ), suite("used to query user information [PERMISSIONS]")( test("return single user for SystemAdmin") { - TestAdminApiClient - .getUser(normalUser.userIri, rootUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/${normalUser.userIri}", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return single user for itself") { - TestAdminApiClient - .getUser(normalUser.userIri, normalUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/${normalUser.userIri}", normalUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return only public information for single user for non SystemAdmin and self") { - TestAdminApiClient - .getUser(normalUser.userIri, projectAdminUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/${normalUser.userIri}", projectAdminUser) .flatMap(_.assert200) .map(_.user) .flatMap(user => @@ -91,8 +89,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { ) }, test("return only public information for single user with anonymous access") { - TestAdminApiClient - .getUser(normalUser.userIri) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/${normalUser.userIri}") .flatMap(_.assert200) .map(_.user) .map(user => @@ -106,18 +104,18 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { ) }, test("return all users for SystemAdmin") { - TestAdminApiClient - .getAllUsers(rootUser) + TestApiClient + .getJson[UsersResponse](uri"/admin/users/", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return all users for ProjectAdmin") { - TestAdminApiClient - .getAllUsers(projectAdminUser) + TestApiClient + .getJson[UsersResponse](uri"/admin/users/", projectAdminUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("return 'Forbidden' for all users for normal user") { - TestAdminApiClient - .getAllUsers(normalUser) + TestApiClient + .getJson[UsersResponse](uri"/admin/users/", normalUser) .map(response => assertTrue(response.code == StatusCode.Forbidden)) }, ), @@ -150,8 +148,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .flatMap(_.assert200) .map(result => assertTrue(result.user.id == customUserIri.value)) }, @@ -167,8 +165,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, ), @@ -185,8 +183,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .flatMap(_.assert200) .flatMap(result => assertTrue( @@ -201,8 +199,12 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { givenName = Some(GivenName.unsafeFrom("Updated\tGivenName")), familyName = Some(FamilyName.unsafeFrom("Updated\"FamilyName")), ) - TestAdminApiClient - .updateUserBasicInfo(otherCustomUserIri, updateUserRequest, rootUser) + TestApiClient + .putJson[UserResponse, BasicUserInformationChangeRequest]( + uri"/admin/users/iri/$otherCustomUserIri/BasicUserInformation", + updateUserRequest, + rootUser, + ) .flatMap(_.assert200) .map(result => assertTrue( @@ -214,8 +216,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { test( "return the special characters correctly when getting a user with special characters in givenName and familyName", ) { - TestAdminApiClient - .getUser(otherCustomUserIri, rootUser) + TestApiClient + .getJson[UserResponse](uri"/admin/users/iri/$otherCustomUserIri", rootUser) .flatMap(_.assert200) .map(result => assertTrue( @@ -237,8 +239,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, projectAdminUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, projectAdminUser) .map(response => assertTrue(response.code == StatusCode.Forbidden)) }, test("create the user if the supplied email and username are unique") { @@ -252,8 +254,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .flatMap(_.assert200) .tap(result => ZIO.succeed(donaldIri.set(result.user.id))) .map(result => @@ -278,8 +280,8 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("return a 'BadRequest' if the supplied email is not unique") { @@ -293,16 +295,13 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { lang = LanguageCode.EN, systemAdmin = SystemAdmin.IsNotSystemAdmin, ) - TestAdminApiClient - .createUser(createUserRequest, rootUser) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", createUserRequest, rootUser) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("authenticate the newly created user using HttpBasicAuth") { TestApiClient - .getJson[zio.json.ast.Json]( - uri"/v2/authentication", - _.auth.basic("donald.duck@example.org", "test"), - ) + .getJson[Json](uri"/v2/authentication", _.auth.basic("donald.duck@example.org", "test")) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("authenticate the newly created user during login") { @@ -323,8 +322,12 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { familyName = Some(FamilyName.unsafeFrom("Duckmann")), lang = Some(LanguageCode.DE), ) - TestAdminApiClient - .updateUserBasicInfo(donaldIri.asUserIri, updateUserRequest, rootUser) + TestApiClient + .putJson[UserResponse, BasicUserInformationChangeRequest]( + uri"/admin/users/iri/$donaldIri/BasicUserInformation", + updateUserRequest, + rootUser, + ) .flatMap(_.assert200) .map(result => assertTrue( @@ -341,8 +344,12 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { requesterPassword = Password.unsafeFrom("test"), newPassword = Password.unsafeFrom("will-be-ignored"), ) - TestAdminApiClient - .updateUserPassword(customUserIri, changeUserPasswordRequest, normalUser) + TestApiClient + .putJson[UserResponse, PasswordChangeRequest]( + uri"/admin/users/iri/$customUserIri/Password", + changeUserPasswordRequest, + normalUser, + ) .map(response => assertTrue(response.code == StatusCode.Forbidden)) }, test("update the user's password (by himself)") { @@ -351,11 +358,15 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { newPassword = Password.unsafeFrom("test123456"), ) for { - _ <- TestAdminApiClient - .updateUserPassword(normalUser.userIri, changeUserPasswordRequest, normalUser) + _ <- TestApiClient + .putJson[UserResponse, PasswordChangeRequest]( + uri"/admin/users/iri/${normalUser.id}/Password", + changeUserPasswordRequest, + normalUser, + ) .flatMap(_.assert200) // check if the password was changed, i.e. if the new one is accepted - response <- TestApiClient.getJson[zio.json.ast.Json]( + response <- TestApiClient.getJson[Json]( uri"/v2/authentication", _.auth.basic(normalUser.email, "test123456"), ) @@ -367,11 +378,15 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { newPassword = Password.unsafeFrom("test654321"), ) for { - _ <- TestAdminApiClient - .updateUserPassword(normalUser.userIri, changeUserPasswordRequest, rootUser) + _ <- TestApiClient + .putJson[UserResponse, PasswordChangeRequest]( + uri"/admin/users/iri/${normalUser.id}/Password", + changeUserPasswordRequest, + rootUser, + ) .flatMap(_.assert200) // check if the password was changed, i.e. if the new one is accepted - response <- TestApiClient.getJson[zio.json.ast.Json]( + response <- TestApiClient.getJson[Json]( uri"/v2/authentication", _.auth.basic(normalUser.email, "test654321"), ) @@ -383,13 +398,13 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { | "requesterPassword": "test" |}""".stripMargin for { - response1 <- TestApiClient.putJson[zio.json.ast.Json, String]( - uri"/admin/users/iri/${normalUser.userIri}/Password", + response1 <- TestApiClient.putJson[Json, String]( + uri"/admin/users/iri/${normalUser.id}/Password", changeUserPasswordRequest, normalUser, ) // check that the password was not changed, i.e. the old one is still accepted - response2 <- TestApiClient.getJson[zio.json.ast.Json]( + response2 <- TestApiClient.getJson[Json]( uri"/v2/authentication", _.auth.basic(normalUser.email, "test654321"), ) @@ -399,19 +414,28 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { ) }, test("change user's status") { - TestAdminApiClient - .updateUserStatus(donaldIri.asUserIri, StatusChangeRequest(UserStatus.Inactive), rootUser) + TestApiClient + .putJson[UserResponse, StatusChangeRequest]( + uri"/admin/users/iri/$donaldIri/Status", + StatusChangeRequest(UserStatus.Inactive), + rootUser, + ) .flatMap(_.assert200) .map(result => assertTrue(!result.user.status)) }, test("update the user's system admin membership status") { val changeReq = SystemAdminChangeRequest(SystemAdmin.IsSystemAdmin) for { - response <- TestAdminApiClient.updateUserSystemAdmin(donaldIri.asUserIri, changeReq, rootUser) - result <- response.assert200 + result <- TestApiClient + .putJson[UserResponse, SystemAdminChangeRequest]( + uri"/admin/users/iri/$donaldIri/SystemAdmin", + changeReq, + rootUser, + ) + .flatMap(_.assert200) // Throw BadRequest exception if user is built-in user - badResponse <- TestAdminApiClient.updateUserSystemAdmin( - KnoraSystemInstances.Users.SystemUser.userIri, + badResponse <- TestApiClient.putJson[UserResponse, SystemAdminChangeRequest]( + uri"/admin/users/iri/${KnoraSystemInstances.Users.SystemUser.userIri}/SystemAdmin", changeReq, rootUser, ) @@ -423,255 +447,47 @@ object AdminUsersEndpointsE2ESpec extends E2EZSpec { ) }, test("not allow updating the system user's system admin membership status") { - TestAdminApiClient - .updateUserSystemAdmin( - KnoraSystemInstances.Users.SystemUser.userIri, + TestApiClient + .putJson[UserResponse, SystemAdminChangeRequest]( + uri"/admin/users/iri/${KnoraSystemInstances.Users.SystemUser.id}/SystemAdmin", SystemAdminChangeRequest(SystemAdmin.IsSystemAdmin), rootUser, ) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("not allow changing the system user's status") { - TestAdminApiClient - .updateUserStatus( - KnoraSystemInstances.Users.SystemUser.userIri, + TestApiClient + .putJson[UserResponse, StatusChangeRequest]( + uri"/admin/users/iri/${KnoraSystemInstances.Users.SystemUser.id}/Status", StatusChangeRequest(UserStatus.Inactive), rootUser, ) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("not allow changing the anonymous user's status") { - TestAdminApiClient - .updateUserStatus( - UserIri.unsafeFrom(KnoraSystemInstances.Users.AnonymousUser.id), + TestApiClient + .putJson[UserResponse, StatusChangeRequest]( + uri"/admin/users/iri/${KnoraSystemInstances.Users.AnonymousUser.id}/Status", StatusChangeRequest(UserStatus.Inactive), rootUser, ) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("delete a user") { - TestAdminApiClient - .deleteUser(customUserIri, rootUser) + TestApiClient + .deleteJson[UserResponse](uri"/admin/users/iri/$customUserIri", rootUser) .map(response => assertTrue(response.code == StatusCode.Ok)) }, test("not allow deleting the system user") { - TestAdminApiClient - .deleteUser(KnoraSystemInstances.Users.SystemUser.userIri, rootUser) + TestApiClient + .deleteJson[UserResponse](uri"/admin/users/iri/${KnoraSystemInstances.Users.SystemUser.id}", rootUser) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, test("not allow deleting the anonymous user") { - TestAdminApiClient - .deleteUser(UserIri.unsafeFrom(KnoraSystemInstances.Users.AnonymousUser.id), rootUser) + TestApiClient + .deleteJson[UserResponse](uri"/admin/users/iri/${KnoraSystemInstances.Users.AnonymousUser.id}", rootUser) .map(response => assertTrue(response.code == StatusCode.BadRequest)) }, ), - suite("used to query project memberships")( - test("return all projects the user is a member of") { - TestAdminApiClient - .getUserProjectMemberships(multiUserIri, rootUser) - .flatMap(_.assert200) - .map(result => - assertTrue( - result.projects.contains(imagesProjectExternal), - result.projects.contains(incunabulaProjectExternal), - result.projects.contains(anythingProjectExternal), - ), - ) - }, - ), - suite("used to modify project membership")( - test("NOT add a user to project if the requesting user is not a SystemAdmin or ProjectAdmin") { - TestAdminApiClient - .addUserToProject(normalUser.userIri, imagesProjectIri, normalUser) - .map(response => assertTrue(response.code == StatusCode.Forbidden)) - }, - test("add user to project") { - for { - beforeResponse <- TestAdminApiClient.getUserProjectMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - beforeResult <- beforeResponse.assert200 - _ <- TestAdminApiClient.addUserToProject(normalUser.userIri, imagesProjectIri, rootUser).flatMap(_.assert200) - afterResponse <- TestAdminApiClient.getUserProjectMemberships(normalUser.userIri, rootUser) - afterResult <- afterResponse.assert200 - } yield assertTrue( - beforeResult.projects == Seq.empty, - afterResult.projects == Seq(SharedTestDataADM.imagesProjectExternal), - ) - }, - test("don't add user to project if user is already a member") { - for { - beforeResponse <- TestAdminApiClient.getUserProjectMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - beforeResult <- beforeResponse.assert200 - response <- TestAdminApiClient.addUserToProject(normalUser.userIri, imagesProjectIri, rootUser) - afterResponse <- TestAdminApiClient.getUserProjectMemberships(normalUser.userIri, rootUser) - afterResult <- afterResponse.assert200 - } yield assertTrue( - response.code == StatusCode.BadRequest, - afterResult.projects == beforeResult.projects, - ) - }, - test("remove user from project") { - for { - beforeResponse <- TestAdminApiClient.getUserProjectMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - beforeResult <- beforeResponse.assert200 - response <- TestAdminApiClient.removeUserFromProject(normalUser.userIri, imagesProjectIri, rootUser) - afterResponse <- TestAdminApiClient.getUserProjectMemberships(normalUser.userIri, rootUser) - afterResult <- afterResponse.assert200 - } yield assertTrue( - beforeResult.projects == Seq(SharedTestDataADM.imagesProjectExternal), - response.code == StatusCode.Ok, - afterResult.projects == Seq.empty[Project], - ) - }, - ), - suite("used to query project admin group memberships")( - test("return all projects the user is a member of the project admin group") { - TestAdminApiClient - .getUserProjectAdminMemberships(multiUserIri, rootUser) - .flatMap(_.assert200) - .map(result => - assertTrue( - result.projects.contains(imagesProjectExternal) && - result.projects.contains(incunabulaProjectExternal) && - result.projects.contains(anythingProjectExternal), - ), - ) - }, - ), - suite("used to modify project admin group membership")( - test("add user to project admin group only if he is already member of that project") { - for { - // add user as project admin to images project - returns a BadRequest because user is not member of the project - responseWithoutBeingMember <- TestAdminApiClient.addUserToProjectAdmin( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - // add user as member to images project - responseAddUserToProject <- TestAdminApiClient.addUserToProject( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - // verify that user is not yet project admin in images project - membershipsBeforeResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsBeforeResult <- membershipsBeforeResponse.assert200 - // add user as project admin to images project - response <- TestAdminApiClient.addUserToProjectAdmin( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - // verify that user has been added as project admin to images project - membershipsAfterResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsAfterResult <- membershipsAfterResponse.assert200 - } yield assertTrue( - responseWithoutBeingMember.code == StatusCode.BadRequest, - responseAddUserToProject.code == StatusCode.Ok, - membershipsBeforeResult.projects == Seq(), - response.code == StatusCode.Ok, - membershipsAfterResult.projects == Seq(SharedTestDataADM.imagesProjectExternal), - ) - }, - test("remove user from project admin group") { - for { - membershipsBeforeResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsBeforeResult <- membershipsBeforeResponse.assert200 - response <- TestAdminApiClient.removeUserFromProjectAdmin( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - membershipsAfterResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsAfterResult <- membershipsAfterResponse.assert200 - } yield assertTrue( - membershipsBeforeResult.projects == Seq(SharedTestDataADM.imagesProjectExternal), - response.code == StatusCode.Ok, - membershipsAfterResult.projects == Seq.empty[Project], - ) - }, - test("remove user from project which also removes him from project admin group") { - for { - // add user as project admin to images project - responseAddUserAsProjectAdmin <- TestAdminApiClient.addUserToProjectAdmin( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - // verify that user has been added as project admin to images project - membershipsBeforeResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsBeforeResult <- membershipsBeforeResponse.assert200 - // remove user as project member from images project - response <- TestAdminApiClient.removeUserFromProject( - normalUser.userIri, - imagesProjectIri, - rootUser, - ) - // verify that user has also been removed as project admin from images project - projectAdminMembershipsAfterResponse <- - TestAdminApiClient.getUserProjectAdminMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - projectAdminMembershipsAfterResult <- projectAdminMembershipsAfterResponse.assert200 - } yield assertTrue( - responseAddUserAsProjectAdmin.code == StatusCode.Ok, - membershipsBeforeResult.projects == Seq(SharedTestDataADM.imagesProjectExternal), - response.code == StatusCode.Ok, - projectAdminMembershipsAfterResult.projects == Seq(), - ) - }, - ), - suite("used to query group memberships")( - test("return all groups the user is a member of") { - TestAdminApiClient - .getUserGroupMemberships(multiUserIri, rootUser) - .flatMap(_.assert200) - .map(result => - assertTrue( - result.groups.contains(SharedTestDataADM.imagesReviewerGroupExternal), - ), - ) - }, - ), - suite("used to modify group membership")( - test("add user to group") { - for { - membershipsBeforeResponse <- - TestAdminApiClient.getUserGroupMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsBeforeResult <- membershipsBeforeResponse.assert200 - response <- TestAdminApiClient.addUserToGroup( - normalUser.userIri, - imagesReviewerGroup.groupIri, - rootUser, - ) - membershipsAfterResponse <- TestAdminApiClient.getUserGroupMemberships(normalUser.userIri, rootUser) - membershipsAfterResult <- membershipsAfterResponse.assert200 - } yield assertTrue( - membershipsBeforeResult.groups == Seq.empty[Group], - response.code == StatusCode.Ok, - membershipsAfterResult.groups == Seq(SharedTestDataADM.imagesReviewerGroupExternal), - ) - }, - test("remove user from group") { - for { - membershipsBeforeResponse <- - TestAdminApiClient.getUserGroupMemberships(UserIri.unsafeFrom(normalUser.id), rootUser) - membershipsBeforeResult <- membershipsBeforeResponse.assert200 - response <- TestAdminApiClient.removeUserFromGroup( - normalUser.userIri, - imagesReviewerGroup.groupIri, - rootUser, - ) - membershipsAfterResponse <- TestAdminApiClient.getUserGroupMemberships(normalUser.userIri, rootUser) - membershipsAfterResult <- membershipsAfterResponse.assert200 - } yield assertTrue( - membershipsBeforeResult.groups == Seq(SharedTestDataADM.imagesReviewerGroupExternal), - response.code == StatusCode.Ok, - membershipsAfterResult.groups == Seq.empty[Group], - ) - }, - ), ) } diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersGroupMembershipsEndpointsE2ESpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersGroupMembershipsEndpointsE2ESpec.scala new file mode 100644 index 00000000000..4655763717d --- /dev/null +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersGroupMembershipsEndpointsE2ESpec.scala @@ -0,0 +1,90 @@ +/* + * 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.admin.api + +import sttp.client4.UriContext +import sttp.model.StatusCode +import zio.test.* + +import org.knora.webapi.* +import org.knora.webapi.messages.admin.responder.usersmessages.UserGroupMembershipsGetResponseADM +import org.knora.webapi.sharedtestdata.SharedTestDataADM +import org.knora.webapi.sharedtestdata.SharedTestDataADM.* +import org.knora.webapi.sharedtestdata.SharedTestDataADM2 +import org.knora.webapi.slice.admin.api.service.UserRestService.UserResponse +import org.knora.webapi.slice.admin.domain.model.* +import org.knora.webapi.testservices.ResponseOps +import org.knora.webapi.testservices.ResponseOps.assert200 +import org.knora.webapi.testservices.TestApiClient + +object AdminUsersGroupMembershipsEndpointsE2ESpec extends E2EZSpec { + + private val multiUserIri = UserIri.unsafeFrom(SharedTestDataADM2.multiuserUser.userData.user_id.get) + + override val e2eSpec = suite( + "The Users Route ('admin/users/iri/:userIri/group-member-ships') ", + )( + suite("used to query group memberships")( + test("return all groups the user is a member of") { + TestApiClient + .getJson[UserGroupMembershipsGetResponseADM](uri"/admin/users/iri/$multiUserIri/group-memberships", rootUser) + .flatMap(_.assert200) + .map(result => + assertTrue( + result.groups.contains(SharedTestDataADM.imagesReviewerGroupExternal), + ), + ) + }, + ), + suite("used to modify group membership")( + test("add user to group") { + for { + _ <- TestApiClient + .getJson[UserGroupMembershipsGetResponseADM]( + uri"/admin/users/iri/${normalUser.id}/group-memberships", + rootUser, + ) + .flatMap(_.assert200) + .filterOrFail(_.groups.isEmpty)(IllegalStateException("User is already member of a group")) + response <- TestApiClient.postJson[UserResponse, String]( + uri"/admin/users/iri/${normalUser.id}/group-memberships/${imagesReviewerGroup.groupIri}", + "", + rootUser, + ) + membershipsAfterResult <- TestApiClient + .getJson[UserGroupMembershipsGetResponseADM]( + uri"/admin/users/iri/${normalUser.id}/group-memberships", + rootUser, + ) + .flatMap(_.assert200) + } yield assertTrue(membershipsAfterResult.groups == Seq(SharedTestDataADM.imagesReviewerGroupExternal)) + }, + test("remove user from group") { + for { + membershipsBeforeResponse <- + TestApiClient.getJson[UserGroupMembershipsGetResponseADM]( + uri"/admin/users/iri/${normalUser.id}/group-memberships", + rootUser, + ) + membershipsBeforeResult <- membershipsBeforeResponse.assert200 + response <- TestApiClient.deleteJson[UserResponse]( + uri"/admin/users/iri/${normalUser.id}/group-memberships/${imagesReviewerGroup.groupIri}", + rootUser, + ) + membershipsAfterResponse <- TestApiClient.getJson[UserGroupMembershipsGetResponseADM]( + uri"/admin/users/iri/${normalUser.id}/group-memberships", + rootUser, + ) + membershipsAfterResult <- membershipsAfterResponse.assert200 + } yield assertTrue( + membershipsBeforeResult.groups == Seq(SharedTestDataADM.imagesReviewerGroupExternal), + response.code == StatusCode.Ok, + membershipsAfterResult.groups == Seq.empty[Group], + ) + }, + ), + ) +} diff --git a/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersProjectMemberShipsEndpointsE2ESpec.scala b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersProjectMemberShipsEndpointsE2ESpec.scala new file mode 100644 index 00000000000..6c3fa14f488 --- /dev/null +++ b/modules/test-e2e/src/test/scala/org/knora/webapi/slice/admin/api/AdminUsersProjectMemberShipsEndpointsE2ESpec.scala @@ -0,0 +1,213 @@ +/* + * 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.admin.api + +import sttp.client4.UriContext +import sttp.model.StatusCode +import zio.* +import zio.json.* +import zio.test.* + +import org.knora.webapi.* +import org.knora.webapi.messages.admin.responder.usersmessages.UserProjectAdminMembershipsGetResponseADM +import org.knora.webapi.messages.admin.responder.usersmessages.UserProjectMembershipsGetResponseADM +import org.knora.webapi.sharedtestdata.SharedTestDataADM +import org.knora.webapi.sharedtestdata.SharedTestDataADM.* +import org.knora.webapi.sharedtestdata.SharedTestDataADM2 +import org.knora.webapi.slice.admin.api.AdminUsersProjectMemberShipsEndpointsE2ESpec.faker +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest +import org.knora.webapi.slice.admin.api.model.Project +import org.knora.webapi.slice.admin.api.service.UserRestService.UserResponse +import org.knora.webapi.slice.admin.domain.model.* +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.common.domain.LanguageCode +import org.knora.webapi.slice.common.domain.LanguageCode.DE +import org.knora.webapi.testservices.ResponseOps +import org.knora.webapi.testservices.ResponseOps.assert200 +import org.knora.webapi.testservices.TestApiClient +import org.knora.webapi.util.ZioHelper.addLogTiming + +object AdminUsersProjectMemberShipsEndpointsE2ESpec extends E2EZSpec { + + private val multiUserIri = UserIri.unsafeFrom(SharedTestDataADM2.multiuserUser.userData.user_id.get) + + private def createNewUser = + val firstName = faker.name().firstName() + val lastName = faker.name().lastName() + val req = UserCreateRequest( + id = None, + Username.unsafeFrom(s"$firstName.${faker.number().positive()}"), + Email.unsafeFrom(s"$firstName.$lastName@example.org"), + GivenName.unsafeFrom(firstName), + FamilyName.unsafeFrom(lastName), + Password.unsafeFrom(faker.credentials().password(8, 16)), + UserStatus.Active, + DE, + SystemAdmin.IsNotSystemAdmin, + ) + TestApiClient + .postJson[UserResponse, UserCreateRequest](uri"/admin/users", req, rootUser) + .flatMap(_.assert200) + .map(_.user) + + override val e2eSpec = suite( + "The Users Routes ('admin/users/iri/:userIri/project-memberships', 'admin/users/iri/:userIri/project-admin-memberships') ", + )( + suite("used to query project memberships")( + test("return all projects the user is a member of") { + getProjectMemberships(multiUserIri) + .flatMap(_.assert200) + .map(result => + assertTrue( + result.projects.contains(imagesProjectExternal), + result.projects.contains(incunabulaProjectExternal), + result.projects.contains(anythingProjectExternal), + ), + ) + }, + ), + suite("used to modify project membership")( + test("NOT add a user to project if the requesting user is not a SystemAdmin or ProjectAdmin") { + for { + newUser <- createNewUser + + response <- addUserToProject(newUser.userIri, imagesProjectExternal.id, normalUser) + + } yield assertTrue(response.code == StatusCode.Forbidden) + }, + test("add user to project") { + for { + newUser <- createNewUser + + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + projectMemberships <- getProjectMemberships(newUser.userIri).flatMap(_.assert200) + } yield assertTrue(projectMemberships.projects == Seq(imagesProjectExternal)) + } @@ TestAspect.timeout(5.seconds) @@ TestAspect.flaky, + test("don't add user to project if user is already a member") { + for { + newUser <- createNewUser + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + response <- addUserToProject(newUser.userIri, imagesProjectExternal.id) + + } yield assertTrue(response.code == StatusCode.BadRequest) + }, + test("remove user from project") { + for { + newUser <- createNewUser + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + _ <- removeUserFromProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + projectMemberships <- getProjectMemberships(newUser.userIri).flatMap(_.assert200) + } yield assertTrue(projectMemberships.projects == Seq.empty) + }, + ), + suite("used to query project admin group memberships")( + test("return all projects the user is a member of the project admin group") { + getProjectAdminMemberships(multiUserIri) + .flatMap(_.assert200) + .map(result => + assertTrue( + result.projects.contains(imagesProjectExternal), + result.projects.contains(incunabulaProjectExternal), + result.projects.contains(anythingProjectExternal), + ), + ) + }, + ), + suite("used to modify project admin group membership")( + test("do NOT add user to project admin group if not member of that project") { + for { + newUser <- createNewUser + + response <- addUserToProjectAsAdmin(newUser.userIri, imagesProjectExternal.id) + + } yield assertTrue(response.code == StatusCode.BadRequest) + }, + test("add user to project admin group if member of that project") { + for { + newUser <- createNewUser + + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + _ <- addUserToProjectAsAdmin(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + adminMemberships <- getProjectAdminMemberships(newUser.userIri).flatMap(_.assert200) + } yield assertTrue(adminMemberships.projects == Seq(imagesProjectExternal)) + } @@ TestAspect.timeout(5.seconds) @@ TestAspect.flaky, + test("remove user from project admin group") { + for { + newUser <- createNewUser + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + _ <- addUserToProjectAsAdmin(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + _ <- removeUserFromProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + adminMemberships <- getProjectAdminMemberships(newUser.userIri).flatMap(_.assert200) + } yield assertTrue(adminMemberships.projects == Seq.empty) + }, + test("remove user from project which also removes them from project admin group") { + for { + newUser <- createNewUser + _ <- addUserToProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + _ <- addUserToProjectAsAdmin(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + _ <- removeUserFromProject(newUser.userIri, imagesProjectExternal.id).flatMap(_.assert200) + + adminMemberships <- getProjectAdminMemberships(normalUser.userIri).flatMap(_.assert200) + } yield assertTrue(adminMemberships.projects == Seq.empty) + }, + ), + ) + + private def getProjectMemberships(userIri: UserIri, requestingUser: User = rootUser) = + addLogTiming("GET project-memberships") { + TestApiClient + .getJson[UserProjectMembershipsGetResponseADM]( + uri"/admin/users/iri/$userIri/project-memberships", + requestingUser, + ) + } + + private def getProjectAdminMemberships(userIri: UserIri, requestingUser: User = rootUser) = + addLogTiming("GET project-admin-memberships") { + TestApiClient + .getJson[UserProjectAdminMembershipsGetResponseADM]( + uri"/admin/users/iri/$userIri/project-admin-memberships", + requestingUser, + ) + } + + private def addUserToProject(userIri: UserIri, projectIri: ProjectIri, requestingUser: User = rootUser) = + addLogTiming("POST project-memberships") { + TestApiClient + .postJson[UserResponse, String]( + uri"/admin/users/iri/$userIri/project-memberships/$projectIri", + "", + requestingUser, + ) + } + + private def addUserToProjectAsAdmin(userIri: UserIri, projectIri: ProjectIri, requestingUser: User = rootUser) = + addLogTiming("POST project-admin-memberships") { + TestApiClient + .postJson[UserResponse, String]( + uri"/admin/users/iri/$userIri/project-admin-memberships/$projectIri", + "", + requestingUser, + ) + } + + private def removeUserFromProject(userIri: UserIri, projectIri: ProjectIri, requestingUser: User = rootUser) = + addLogTiming("DELETE project-memberships") { + TestApiClient + .deleteJson[UserResponse]( + uri"/admin/users/iri/$userIri/project-memberships/$projectIri", + requestingUser, + ) + } +} diff --git a/modules/test-it/src/test/resources/application.conf b/modules/test-it/src/test/resources/application.conf index e3ba0c82aeb..c707248e0c1 100644 --- a/modules/test-it/src/test/resources/application.conf +++ b/modules/test-it/src/test/resources/application.conf @@ -1,5 +1,3 @@ -include "test" - app { triplestore { dbtype = "fuseki" diff --git a/modules/test-it/src/test/resources/test.conf b/modules/test-it/src/test/resources/test.conf deleted file mode 100644 index b8aecaf3877..00000000000 --- a/modules/test-it/src/test/resources/test.conf +++ /dev/null @@ -1,29 +0,0 @@ -akka { - log-config-on-start = false - loggers = ["akka.event.slf4j.Slf4jLogger"] - logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" - loglevel = "ERROR" - stdout-loglevel = "ERROR" - log-dead-letters = off - log-dead-letters-during-shutdown = off - - actor { - default-dispatcher { - executor = "fork-join-executor" - fork-join-executor { - parallelism-min = 8 - parallelism-factor = 2.0 - parallelism-max = 8 - } - } - } - - http.host-connection-pool.response-entity-subscription-timeout = 10 seconds - - # The time period within which the TCP binding process must be completed. - http.server.bind-timeout = 15 seconds -} - -app { - allow-reload-over-http = true -} diff --git a/modules/test-it/src/test/scala/org/knora/webapi/responders/admin/ListsResponderSpec.scala b/modules/test-it/src/test/scala/org/knora/webapi/responders/admin/ListsResponderSpec.scala index ca02b5a88bd..51f910c8e7b 100644 --- a/modules/test-it/src/test/scala/org/knora/webapi/responders/admin/ListsResponderSpec.scala +++ b/modules/test-it/src/test/scala/org/knora/webapi/responders/admin/ListsResponderSpec.scala @@ -100,7 +100,7 @@ object ListsResponderSpec extends E2EZSpec { .map(actual => assertTrue(actual.nodeinfo.sorted == summerNodeInfo.sorted)) }, test("return a full list response") { - listsResponder(_.listGetRequestADM("http://rdfh.ch/lists/0001/treeList")) + listsResponder(_.listGetRequestADM(ListIri.unsafeFrom("http://rdfh.ch/lists/0001/treeList"))) .flatMap(expectType[ListGetResponseADM]) .map(actual => assertTrue( @@ -423,7 +423,7 @@ object ListsResponderSpec extends E2EZSpec { UUID.randomUUID, ), ).map(_.node) - val actual = listsResponder(_.listGetRequestADM(oldParentIri.value)).flatMap(expectType[ListNodeGetResponseADM]) + val actual = listsResponder(_.listGetRequestADM(oldParentIri)).flatMap(expectType[ListNodeGetResponseADM]) (parentNode <*> actual).map { (parentNode: ListNodeADM, actual: ListNodeGetResponseADM) => @@ -471,7 +471,7 @@ object ListsResponderSpec extends E2EZSpec { ), ).map(_.node) - val actual = listsResponder(_.listGetRequestADM(oldParentIri.value)) + val actual = listsResponder(_.listGetRequestADM(oldParentIri)) .flatMap(expectType[ListNodeGetResponseADM]) (parentNode <*> actual).map { (parentNode: ListNodeADM, actual: ListNodeGetResponseADM) => diff --git a/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala b/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala index 8a77b3abdf5..60be2755346 100644 --- a/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala +++ b/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala @@ -51,6 +51,7 @@ import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select import org.knora.webapi.util.MutableTestIri +import org.knora.webapi.util.OntologyTestHelper object OntologyResponderV2Spec extends E2EZSpec { self => @@ -182,13 +183,6 @@ object OntologyResponderV2Spec extends E2EZSpec { self => ontologySchema = ApiV2Complex, ) - private def getLastModificationDate(r: ReadOntologyMetadataV2, ontologyIri: OntologyIri): Instant = - r.toOntologySchema(ApiV2Complex) - .ontologies - .find(_.ontologyIri == ontologyIri.toComplexSchema) - .flatMap(_.lastModificationDate) - .getOrElse(throw AssertionException(s"$ontologyIri has no last modification date")) - override val e2eSpec = suite("The ontology responder v2")( test("create an empty ontology called 'foo' with a project code") { val createReq = @@ -403,7 +397,7 @@ object OntologyResponderV2Spec extends E2EZSpec { self => test("not delete the 'anything' ontology, because it is used in data and in the 'something' ontology") { for { metadataResponse <- ontologyResponder(_.getOntologyMetadataForProject(anythingProjectIri)) - anythingLastModDate = getLastModificationDate(metadataResponse, anythingOntologyIri) + anythingLastModDate = OntologyTestHelper.lastModificationDate(metadataResponse, anythingOntologyIri) exit <- ontologyResponder(_.deleteOntology(anythingOntologyIri, anythingLastModDate, randomUUID)).exit } yield assertTrue(metadataResponse.ontologies.size == 3) && assert(exit)( @@ -608,7 +602,7 @@ object OntologyResponderV2Spec extends E2EZSpec { self => ) { for { metadataResponse <- ontologyResponder(_.getOntologyMetadataForProject(anythingProjectIri)) - anythingLastModDate = getLastModificationDate(metadataResponse, anythingOntologyIri) + anythingLastModDate = OntologyTestHelper.lastModificationDate(metadataResponse, anythingOntologyIri) propertyIri = anythingOntologyIri.makeEntityIri("hasInterestingThing") propertyInfoContent = PropertyInfoContentV2( propertyIri = propertyIri, @@ -4860,7 +4854,7 @@ object OntologyResponderV2Spec extends E2EZSpec { self => for { metadataResponse <- ontologyResponder(_.getOntologyMetadataForProject(anythingProjectIri)) - anythingLastModDate = getLastModificationDate(metadataResponse, anythingOntologyIri) + anythingLastModDate = OntologyTestHelper.lastModificationDate(metadataResponse, anythingOntologyIri) // create the property anything:hasFoafName propertyIri: SmartIri = anythingOntologyIri.makeEntityIri("hasFoafName") diff --git a/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/ontology/AddCardinalitiesToClassSpec.scala b/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/ontology/AddCardinalitiesToClassSpec.scala index e3b2c45c8f7..5f6f1938194 100644 --- a/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/ontology/AddCardinalitiesToClassSpec.scala +++ b/modules/test-it/src/test/scala/org/knora/webapi/responders/v2/ontology/AddCardinalitiesToClassSpec.scala @@ -15,7 +15,6 @@ import org.knora.webapi.E2EZSpec import org.knora.webapi.messages.IriConversions.* import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.SmartIri -import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject import org.knora.webapi.messages.store.triplestoremessages.SmartIriLiteralV2 import org.knora.webapi.messages.v2.responder.ontologymessages.* @@ -28,17 +27,17 @@ import org.knora.webapi.slice.common.KnoraIris.PropertyIri import org.knora.webapi.slice.common.KnoraIris.ResourceClassIri import org.knora.webapi.slice.ontology.api.AddCardinalitiesToClassRequestV2 import org.knora.webapi.slice.ontology.domain.model.Cardinality.ZeroOrOne +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select -import org.knora.webapi.testservices.TestOntologyApiClient +import org.knora.webapi.util.OntologyTestHelper /** * This spec is used to test [[org.knora.webapi.responders.v2.ontology.Cardinalities]]. */ object AddCardinalitiesToClassSpec extends E2EZSpec { - private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance - private val ontologyResponder = ZIO.serviceWithZIO[OntologyResponderV2] + private val ontologyResponder = ZIO.serviceWithZIO[OntologyResponderV2] override val rdfDataObjects: List[RdfDataObject] = List(freetestRdfOntology) @@ -69,12 +68,11 @@ object AddCardinalitiesToClassSpec extends E2EZSpec { val newPropertyIri = freetestOntologyIri.makeProperty("hasName") for { - ontologyLastModificationDate <- TestOntologyApiClient.getLastModificationDate(freetestOntologyIri) - // assert that the cardinality for `:hasAuthor` is only once in the triplestore countInitial <- getCardinalityCountFromTriplestore(classIri, propertyIri) // add additional cardinality to the class + ontologyLastModificationDate <- OntologyTestHelper.lastModificationDate(freetestOntologyIri) _ <- ontologyResponder( _.addCardinalitiesToClass( AddCardinalitiesToClassRequestV2( diff --git a/modules/test-it/src/test/scala/org/knora/webapi/util/OntologyTestHelper.scala b/modules/test-it/src/test/scala/org/knora/webapi/util/OntologyTestHelper.scala new file mode 100644 index 00000000000..d9527666ed9 --- /dev/null +++ b/modules/test-it/src/test/scala/org/knora/webapi/util/OntologyTestHelper.scala @@ -0,0 +1,39 @@ +/* + * 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.util +import zio.* + +import java.time.Instant + +import org.knora.webapi.ApiV2Complex +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyMetadataV2 +import org.knora.webapi.messages.v2.responder.ontologymessages.ReadOntologyV2 +import org.knora.webapi.slice.common.KnoraIris.OntologyIri +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo + +object OntologyTestHelper { + private val ontologyRepo = ZIO.serviceWithZIO[OntologyRepo] + def lastModificationDate(ontologyIri: OntologyIri): RIO[OntologyRepo, Instant] = + ontologyRepo( + _.findById(ontologyIri) + .someOrFail(IllegalStateException(s"Ontology $ontologyIri not found")) + .map(lastModificationDate), + ).orDie + + @throws[IllegalStateException] + def lastModificationDate(r: ReadOntologyV2): Instant = + r.ontologyMetadata.lastModificationDate.getOrElse( + throw IllegalStateException(s"${r.ontologyIri} has no last modification date"), + ) + + @throws[IllegalStateException] + def lastModificationDate(r: ReadOntologyMetadataV2, ontologyIri: OntologyIri): Instant = + r.toOntologySchema(ApiV2Complex) + .ontologies + .find(_.ontologyIri == ontologyIri.toComplexSchema) + .flatMap(_.lastModificationDate) + .getOrElse(throw IllegalStateException(s"$ontologyIri has no last modification date")) +} diff --git a/modules/testkit/src/main/scala/E2EZSpec.scala b/modules/testkit/src/main/scala/E2EZSpec.scala deleted file mode 100644 index e74b0bfdb1e..00000000000 --- a/modules/testkit/src/main/scala/E2EZSpec.scala +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 - -import zio.* -import zio.test.* -import zio.test.Assertion.* - -import scala.reflect.ClassTag - -import org.knora.webapi.core.Db -import org.knora.webapi.core.LayersTest -import org.knora.webapi.messages.StringFormatter -import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject -import org.knora.webapi.slice.infrastructure.CacheManager -import org.knora.webapi.util.Logger - -abstract class E2EZSpec extends ZIOSpec[LayersTest.Environment] { - - implicit val sf: StringFormatter = StringFormatter.getInitializedTestInstance - - override val bootstrap: ULayer[LayersTest.Environment] = Logger.text >>> LayersTest.layer - - def rdfDataObjects: List[RdfDataObject] = List.empty - - type env = LayersTest.Environment with Scope - - private def prepare = Db.initWithTestData(rdfDataObjects) *> ZIO.serviceWithZIO[CacheManager](_.clearAll()) - - def e2eSpec: Spec[env, Any] - - final override def spec: Spec[env, Any] = - e2eSpec.provideSomeAuto(Scope.default) - @@ TestAspect.beforeAll(prepare) - @@ TestAspect.sequential - @@ TestAspect.withLiveEnvironment -} - -object E2EZSpec { - def failsWithMessageEqualTo[A <: Throwable](messsage: String)(implicit - tag: ClassTag[A], - ): Assertion[Exit[Any, Any]] = - fails(isSubtype[A](hasMessage(equalTo(messsage)))) - - def failsWithMessageContaining[A <: Throwable](messsage: String)(implicit - tag: ClassTag[A], - ): Assertion[Exit[Any, Any]] = - fails(isSubtype[A](hasMessage(containsString(messsage)))) -} diff --git a/modules/testkit/src/main/scala/RdfMediaTypes.scala b/modules/testkit/src/main/scala/RdfMediaTypes.scala deleted file mode 100644 index 96aa6a038d7..00000000000 --- a/modules/testkit/src/main/scala/RdfMediaTypes.scala +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 - -import org.apache.pekko.http.scaladsl.model.HttpCharsets -import org.apache.pekko.http.scaladsl.model.MediaType - -object RdfMediaTypes { - val `application/ld+json`: MediaType.WithFixedCharset = MediaType.customWithFixedCharset( - mainType = "application", - subType = "ld+json", - charset = HttpCharsets.`UTF-8`, - fileExtensions = List("jsonld"), - ) - - val `text/turtle`: MediaType.WithFixedCharset = MediaType.customWithFixedCharset( - mainType = "text", - subType = "turtle", - charset = HttpCharsets.`UTF-8`, - fileExtensions = List("ttl"), - ) - - val `application/rdf+xml`: MediaType.WithOpenCharset = MediaType.customWithOpenCharset( - mainType = "application", - subType = "rdf+xml", - fileExtensions = List("rdf"), - ) - - val `application/sparql-query`: MediaType.WithFixedCharset = MediaType.customWithFixedCharset( - mainType = "application", - subType = "sparql-query", - charset = HttpCharsets.`UTF-8`, - fileExtensions = List("rq"), - ) -} diff --git a/modules/testkit/src/main/scala/org/knora/webapi/E2EZSpec.scala b/modules/testkit/src/main/scala/org/knora/webapi/E2EZSpec.scala new file mode 100644 index 00000000000..4b1e899da5b --- /dev/null +++ b/modules/testkit/src/main/scala/org/knora/webapi/E2EZSpec.scala @@ -0,0 +1,93 @@ +/* + * 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 + +import net.datafaker.Faker +import sttp.client4.Response +import sttp.client4.UriContext +import sttp.model.StatusCode +import zio.* +import zio.json.ast.Json +import zio.logging.* +import zio.test.* +import zio.test.Assertion.* + +import scala.reflect.ClassTag + +import org.knora.webapi.core.Db +import org.knora.webapi.core.DspApiServer +import org.knora.webapi.core.LayersLive +import org.knora.webapi.core.TestContainerLayers +import org.knora.webapi.messages.StringFormatter +import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject +import org.knora.webapi.slice.infrastructure.CacheManager +import org.knora.webapi.testservices.TestApiClient +import org.knora.webapi.testservices.TestClientsModule + +abstract class E2EZSpec extends ZIOSpec[E2EZSpec.Environment] { + + implicit val sf: StringFormatter = StringFormatter.getInitializedTestInstance + val faker: Faker = new Faker() + + private val testLogger: ULayer[Unit] = Runtime.removeDefaultLoggers >>> consoleLogger( + config = { + val default = ConsoleLoggerConfig.default + default.copy(filter = default.filter.withRootLevel(LogLevel.Error)) + }, + ) + + override val bootstrap: ULayer[E2EZSpec.Environment] = + testLogger >>> + TestContainerLayers.all >+> + LayersLive.remainingLayer >+> + TestClientsModule.layer + + def rdfDataObjects: List[RdfDataObject] = List.empty + + type env = E2EZSpec.Environment & Scope + + private def prepare = for { + _ <- Db.initWithTestData(rdfDataObjects) + _ <- ZIO.serviceWithZIO[CacheManager](_.clearAll()) + _ <- (DspApiServer.startup *> ZIO.never).provideSomeAuto(DspApiServer.layer).fork + // wait max 5 seconds until api is ready + _ <- TestApiClient + .getJson[Json](uri"/version") + .repeatWhile(_.code != StatusCode.Ok) + .retry(Schedule.fixed(10.milli)) + .timeout(5.seconds) + .orDie + _ <- ZIO.logInfo("API is ready, start running tests...") + } yield () + + def e2eSpec: Spec[env, Any] + + final override def spec: Spec[env, Any] = + e2eSpec.provideSomeAuto(Scope.default) + @@ TestAspect.beforeAll(prepare) + @@ TestAspect.sequential + @@ TestAspect.withLiveEnvironment +} + +object E2EZSpec { + + type Environment = + // format: off + LayersLive.Environment & + TestClientsModule.Provided & + TestContainerLayers.Environment + // format: on + + def failsWithMessageEqualTo[A <: Throwable](messsage: String)(implicit + tag: ClassTag[A], + ): Assertion[Exit[Any, Any]] = + fails(isSubtype[A](hasMessage(equalTo(messsage)))) + + def failsWithMessageContaining[A <: Throwable](messsage: String)(implicit + tag: ClassTag[A], + ): Assertion[Exit[Any, Any]] = + fails(isSubtype[A](hasMessage(containsString(messsage)))) +} diff --git a/modules/testkit/src/main/scala/org/knora/webapi/core/LayersTest.scala b/modules/testkit/src/main/scala/org/knora/webapi/core/LayersTest.scala deleted file mode 100644 index cc2ed54a0f6..00000000000 --- a/modules/testkit/src/main/scala/org/knora/webapi/core/LayersTest.scala +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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.core - -import org.apache.pekko.actor.ActorSystem -import zio.* - -import org.knora.webapi.config.* -import org.knora.webapi.config.AppConfig.AppConfigurations -import org.knora.webapi.messages.util.* -import org.knora.webapi.messages.util.standoff.StandoffTagUtilV2 -import org.knora.webapi.responders.IriService -import org.knora.webapi.responders.admin.* -import org.knora.webapi.responders.v2.* -import org.knora.webapi.responders.v2.ontology.CardinalityHandler -import org.knora.webapi.routing.* -import org.knora.webapi.slice.admin.domain.service.* -import org.knora.webapi.slice.common.ApiComplexV2JsonLdRequestParser -import org.knora.webapi.slice.common.api.* -import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper -import org.knora.webapi.slice.resources.repo.service.ResourcesRepo -import org.knora.webapi.slice.search.api.SearchApiRoutes -import org.knora.webapi.store.iiif.IIIFRequestMessageHandler -import org.knora.webapi.store.iiif.api.SipiService -import org.knora.webapi.store.triplestore.upgrade.RepositoryUpdater -import org.knora.webapi.testcontainers.DspIngestTestContainer -import org.knora.webapi.testcontainers.FusekiTestContainer -import org.knora.webapi.testcontainers.SipiTestContainer -import org.knora.webapi.testservices.TestClientsModule - -object LayersTest { self => - - type Environment = - // format: off - LayersLive.Environment & - TestClientsModule.Provided & - TestContainerLayers.Environment - // format: on - - /** - * Provides a layer for integration tests which depend on Fuseki and Sipi as Testcontainers. - * @return a [[ULayer]] with the [[DefaultTestEnvironmentWithSipi]] - */ - val layer: ULayer[self.Environment] = { - // Custom bootstrap for tests that uses TestContainer configs instead of LayersLive.bootstrap - val testBootstrap = - TestContainerLayers.all >+> - LayersLive.intermediateLayers1 >+> - LayersLive.intermediateLayers2 >+> - LayersLive.intermediateLayers3 >+> - LayersLive.remainingLayer - - ZLayer.make[self.Environment]( - testBootstrap, - TestClientsModule.layer, - ) - } -} diff --git a/modules/testkit/src/main/scala/org/knora/webapi/core/TestStartupUtils.scala b/modules/testkit/src/main/scala/org/knora/webapi/core/TestStartupUtils.scala deleted file mode 100644 index 48053d6d057..00000000000 --- a/modules/testkit/src/main/scala/org/knora/webapi/core/TestStartupUtils.scala +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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.core - -import zio.* - -import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject -import org.knora.webapi.slice.ontology.repo.service.OntologyCache -import org.knora.webapi.store.triplestore.api.TriplestoreService - -/** - * This trait is only used for testing. It is necessary so that E2E tests will only start - * after the KnoraService is ready. - */ -trait TestStartupUtils { - - /** - * Load the test data and caches - * - * @param rdfDataObjects a list of [[RdfDataObject]] - */ - def prepareRepository( - rdfDataObjects: List[RdfDataObject], - ): ZIO[TriplestoreService with OntologyCache, Throwable, Unit] = - for { - _ <- ZIO.logInfo(s"Loading test data: ${rdfDataObjects.map(_.name).mkString}") - tss <- ZIO.service[TriplestoreService] - _ <- tss.resetTripleStoreContent(rdfDataObjects).timeout(480.seconds) - _ <- ZIO.logInfo("... loading test data done.") - _ <- ZIO.serviceWithZIO[OntologyCache](_.refreshCache()).orDie - } yield () - -} diff --git a/modules/testkit/src/main/scala/org/knora/webapi/testcontainers/SipiTestContainer.scala b/modules/testkit/src/main/scala/org/knora/webapi/testcontainers/SipiTestContainer.scala index 9c162066aad..710879f5dde 100644 --- a/modules/testkit/src/main/scala/org/knora/webapi/testcontainers/SipiTestContainer.scala +++ b/modules/testkit/src/main/scala/org/knora/webapi/testcontainers/SipiTestContainer.scala @@ -20,8 +20,7 @@ import java.net.InetAddress import org.knora.webapi.http.version.BuildInfo import org.knora.webapi.testcontainers.TestContainerOps.toZio -final class SipiTestContainer - extends GenericContainer[SipiTestContainer](s"daschswiss/knora-sipi:${SipiTestContainer.imageVersion}") { +final class SipiTestContainer extends GenericContainer[SipiTestContainer](s"daschswiss/knora-sipi:latest") { def sipiBaseUrl: URL = { val urlString = s"http://${SipiTestContainer.localHostAddress}:$getFirstMappedPort" diff --git a/modules/testkit/src/main/scala/org/knora/webapi/testservices/RequestsUpdates.scala b/modules/testkit/src/main/scala/org/knora/webapi/testservices/RequestsUpdates.scala index c1bc0240e89..384138b75e3 100644 --- a/modules/testkit/src/main/scala/org/knora/webapi/testservices/RequestsUpdates.scala +++ b/modules/testkit/src/main/scala/org/knora/webapi/testservices/RequestsUpdates.scala @@ -5,6 +5,7 @@ package org.knora.webapi.testservices import sttp.client4.Request +import sttp.model.HeaderNames import sttp.model.MediaType object RequestsUpdates { @@ -28,9 +29,9 @@ object RequestsUpdates { def addAcceptHeader[A](accept: MediaType): RequestUpdate[A] = addAcceptHeader(accept.toString) def addAcceptHeaderTurtle[A]: RequestUpdate[A] = - _.header("Accept", "text/turtle") + _.header(HeaderNames.Accept, "text/turtle") def addAcceptHeaderRdfXml[A]: RequestUpdate[A] = - _.header("Accept", "application/rdf+xml") + _.header(HeaderNames.Accept, "application/rdf+xml") def addAcceptHeader[A](accept: String): RequestUpdate[A] = - _.header("Accept", accept) + _.header(HeaderNames.Accept, accept) } diff --git a/modules/testkit/src/main/scala/org/knora/webapi/testservices/TestApiClient.scala b/modules/testkit/src/main/scala/org/knora/webapi/testservices/TestApiClient.scala index 675d76a210f..ec1110b30f8 100644 --- a/modules/testkit/src/main/scala/org/knora/webapi/testservices/TestApiClient.scala +++ b/modules/testkit/src/main/scala/org/knora/webapi/testservices/TestApiClient.scala @@ -46,9 +46,22 @@ final case class TestApiClient( val request: Request[Either[String, A]] = basicRequest .delete(relativeUri) .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) - f(request).send(backend) + sendRequest(f(request)) } + private def sendRequest[A](request: Request[Either[String, A]], user: User): Task[Response[Either[String, A]]] = + sendRequest(request, Some(user)) + + private def sendRequest[A]( + request: Request[Either[String, A]], + user: Option[User] = None, + ): Task[Response[Either[String, A]]] = + addAuthIfNeeded(user, request).flatMap { req => + req +// .copy(options = req.options.copy(readTimeout = 5.seconds.asScala)) + .send(backend) + } + def deleteJsonLd( relativeUri: Uri, user: Option[User], @@ -57,7 +70,7 @@ final case class TestApiClient( val request = update(basicRequest.delete(relativeUri)) .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asString) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def deleteJsonLdDocument( @@ -71,15 +84,14 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asJsonLdDocument), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def getAsString( relativeUri: Uri, f: Request[Either[String, String]] => Request[Either[String, String]], ): Task[Response[Either[String, String]]] = - val request: Request[Either[String, String]] = f(basicRequest.get(relativeUri).response(asString)) - request.send(backend) + sendRequest(f(basicRequest.get(relativeUri).response(asString))) def getJson[A: JsonDecoder](relativeUri: Uri): Task[Response[Either[String, A]]] = getJson(relativeUri, (r: Request[Either[String, A]]) => r) @@ -87,12 +99,9 @@ final case class TestApiClient( def getJson[A: JsonDecoder]( relativeUri: Uri, f: Request[Either[String, A]] => Request[Either[String, A]], - ): Task[Response[Either[String, A]]] = { - val request: Request[Either[String, A]] = basicRequest - .get(relativeUri) - .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.getMessage)) - f(request).send(backend) - } + ): Task[Response[Either[String, A]]] = sendRequest( + f(basicRequest.get(relativeUri).response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.getMessage))), + ) def getJson[A: JsonDecoder](relativeUri: Uri, user: User): Task[Response[Either[String, A]]] = jwtFor(user).flatMap(jwt => getJson(relativeUri, _.auth.bearer(jwt))) @@ -108,7 +117,7 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asString), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def getJsonLdDocument( @@ -122,7 +131,7 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asJsonLdDocument), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } private def addAuthIfNeeded[A](user: Option[User], request: Request[Either[String, A]]) = user match { @@ -134,41 +143,36 @@ final case class TestApiClient( relativeUri: Uri, body: B, user: User, - ): Task[Response[Either[String, A]]] = - jwtFor(user).flatMap { jwt => - basicRequest - .post(relativeUri) - .body(body.toJson) - .contentType(MediaType.ApplicationJson) - .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) - .auth - .bearer(jwt) - .send(backend) - } + ): Task[Response[Either[String, A]]] = { + val request = basicRequest + .post(relativeUri) + .body(body.toJson) + .contentType(MediaType.ApplicationJson) + .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) + sendRequest(request, Some(user)) + } - def postJson[A: JsonDecoder, B: JsonEncoder](relativeUri: Uri, body: B): Task[Response[Either[String, A]]] = - basicRequest + def postJson[A: JsonDecoder, B: JsonEncoder](relativeUri: Uri, body: B): Task[Response[Either[String, A]]] = { + val request = basicRequest .post(relativeUri) .body(body.toJson) .contentType(MediaType.ApplicationJson) .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.getMessage)) - .send(backend) + sendRequest(request) + } def postJsonLd( relativeUri: Uri, jsonLdBody: String, user: User, - ): ZIO[Any, Throwable, Response[Either[String, String]]] = - jwtFor(user).flatMap { jwt => - basicRequest - .post(relativeUri) - .body(jsonLdBody) - .contentType(MediaType.unsafeApply("application", "ld+json")) - .auth - .bearer(jwt) - .response(asString) - .send(backend) - } + ): Task[Response[Either[String, String]]] = { + val request = basicRequest + .post(relativeUri) + .body(jsonLdBody) + .contentType(MediaType.unsafeApply("application", "ld+json")) + .response(asString) + sendRequest(request, user) + } def postJsonLdDocument( relativeUri: Uri, @@ -183,23 +187,20 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asJsonLdDocument), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def postMultiPart[A: JsonDecoder]( relativeUri: Uri, body: Seq[Part[BasicBodyPart]], user: User, - ): Task[Response[Either[String, A]]] = - jwtFor(user).flatMap { jwt => - basicRequest - .post(relativeUri) - .multipartBody(body) - .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) - .auth - .bearer(jwt) - .send(backend) - } + ): Task[Response[Either[String, A]]] = { + val request = basicRequest + .post(relativeUri) + .multipartBody(body) + .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) + sendRequest(request, user) + } def patchJsonLdDocument( relativeUri: Uri, @@ -214,40 +215,34 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asJsonLdDocument), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def putJson[A: JsonDecoder, B: JsonEncoder]( relativeUri: Uri, body: B, user: User, - ): Task[Response[Either[String, A]]] = - jwtFor(user).flatMap { jwt => - basicRequest - .put(relativeUri) - .body(body.toJson) - .contentType(MediaType.ApplicationJson) - .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) - .auth - .bearer(jwt) - .send(backend) - } + ): Task[Response[Either[String, A]]] = { + val request = basicRequest + .put(relativeUri) + .body(body.toJson) + .contentType(MediaType.ApplicationJson) + .response(asJsonAlways[A].mapLeft((e: DeserializationException) => e.body)) + sendRequest(request, user) + } def putJsonLd( relativeUri: Uri, jsonLdBody: String, user: User, - ): ZIO[Any, Throwable, Response[Either[String, String]]] = - jwtFor(user).flatMap { jwt => - basicRequest - .put(relativeUri) - .body(jsonLdBody) - .contentType(MediaType.unsafeApply("application", "ld+json")) - .auth - .bearer(jwt) - .response(asString) - .send(backend) - } + ): ZIO[Any, Throwable, Response[Either[String, String]]] = { + val request = basicRequest + .put(relativeUri) + .body(jsonLdBody) + .contentType(MediaType.unsafeApply("application", "ld+json")) + .response(asString) + sendRequest(request, user) + } def putJsonLdDocument( relativeUri: Uri, @@ -262,18 +257,19 @@ final case class TestApiClient( .contentType(MediaType.unsafeApply("application", "ld+json")) .response(asJsonLdDocument), ) - addAuthIfNeeded(user, request).flatMap(_.send(backend)) + sendRequest(request, user) } def postSparql( relativeUri: Uri, sparqlQuery: String, f: RequestUpdate[String], - ): Task[Response[Either[String, String]]] = - f(basicRequest.post(relativeUri).body(sparqlQuery)) + ): Task[Response[Either[String, String]]] = { + val request = f(basicRequest.post(relativeUri).body(sparqlQuery)) .contentType(MediaType.unsafeApply("application", "sparql-query")) .response(asString) - .send(backend) + sendRequest(request) + } def postSparql( relativeUri: Uri, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 89c1812d819..9b54aed8e92 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -10,15 +10,12 @@ import sbt._ object Dependencies { // should be the same version as in docker-compose.yml, // make sure to use the same version in ops-deploy repository when deploying new DSP releases! - val fusekiImage = "daschswiss/apache-jena-fuseki:5.5.0-1" + val fusekiImage = "daschswiss/apache-jena-fuseki:5.5.0-2" // base image the knora-sipi image is created from val sipiImage = "daschswiss/sipi:v3.16.3" val ScalaVersion = "3.3.6" - val PekkoActorVersion = "1.2.1" - val PekkoHttpVersion = "1.2.0" - val MonocleVersion = "3.3.0" val Rdf4jVersion = "5.1.6" @@ -90,12 +87,6 @@ object Dependencies { val zioTest = "dev.zio" %% "zio-test" % ZioVersion val zioTestSbt = "dev.zio" %% "zio-test-sbt" % ZioVersion - // pekko - val pekkoHttp = "org.apache.pekko" %% "pekko-http" % PekkoHttpVersion - val pekkoHttpCors = "org.apache.pekko" %% "pekko-http-cors" % PekkoHttpVersion - val pekkoSlf4j = "org.apache.pekko" %% "pekko-slf4j" % PekkoActorVersion - val pekkoStream = "org.apache.pekko" %% "pekko-stream" % PekkoActorVersion - // rdf and graph libraries val jenaCore = "org.apache.jena" % "jena-core" % JenaVersion val jenaText = "org.apache.jena" % "jena-text" % JenaVersion @@ -137,6 +128,8 @@ object Dependencies { val scalaCsv = "com.github.tototoshi" %% "scala-csv" % "2.0.0" // test + val dataFaker = "net.datafaker" % "datafaker" % "2.5.1" + val scalaTest = "org.scalatest" %% "scalatest" % "3.2.19" val testcontainers = "org.testcontainers" % "testcontainers" % "1.21.3" @@ -149,7 +142,6 @@ object Dependencies { val tapirVersion = "1.11.46" val tapir = Seq( - "com.softwaremill.sttp.tapir" %% "tapir-pekko-http-server" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-json-zio" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion, @@ -179,10 +171,6 @@ object Dependencies { val webapiTestDependencies = Seq(zioTest, zioTestSbt, wiremock).map(_ % Test) val webapiDependencies = monocle ++ refined ++ Seq( - pekkoHttp, - pekkoHttpCors, - pekkoSlf4j, - pekkoStream, bouncyCastle, commonsLang3, commonsValidator, diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index db166711619..b69ccba2a65 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -1,247 +1,11 @@ -pekko { - log-config-on-start = off - log-config-on-start = ${?KNORA_AKKA_LOG_CONFIG_ON_START} - loggers = ["org.apache.pekko.event.slf4j.Slf4jLogger"] - loglevel = "ERROR" - loglevel = ${?KNORA_AKKA_LOGLEVEL} - stdout-loglevel = "ERROR" - stdout-loglevel = ${?KNORA_AKKA_STDOUT_LOGLEVEL} - logging-filter = "org.apache.pekko.event.slf4j.Slf4jLoggingFilter" - log-dead-letters = off - log-dead-letters-during-shutdown = off - logger-startup-timeout = 30s - - // pekko-http configuration - http { - server { - # The requested maximum length of the queue of incoming connections. - # If the server is busy and the backlog is full the OS will start dropping - # SYN-packets and connection attempts may fail. Note, that the backlog - # size is usually only a maximum size hint for the OS and the OS can - # restrict the number further based on global limits. - backlog = 1024 - - # The time after which an idle connection will be automatically closed. - # Set to `infinite` to completely disable idle connection timeouts. - # - # Must be larger then request-timeout - idle-timeout = 120 minutes - - # Defines the default time period within which the application has to - # produce an HttpResponse for any given HttpRequest it received. - # The timeout begins to run when the *end* of the request has been - # received, so even potentially long uploads can have a short timeout. - # Set to `infinite` to completely disable request timeout checking. - # - # If this setting is not `infinite` the HTTP server layer attaches a - # `Timeout-Access` header to the request, which enables programmatic - # customization of the timeout period and timeout response for each - # request individually. - # - # Must be smaller then idle-timeout - request-timeout = 120 minutes - - # The time period within which the TCP binding process must be completed. - bind-timeout = 5 seconds - - # The maximum number of concurrently accepted connections when using the - # `Http().bindAndHandle` methods. - # - # This setting doesn't apply to the `Http().bind` method which will still - # deliver an unlimited backpressured stream of incoming connections. - # - # Note, that this setting limits the number of the connections on a best-effort basis. - # It does *not* strictly guarantee that the number of established TCP connections will never - # exceed the limit (but it will be approximately correct) because connection termination happens - # asynchronously. It also does *not* guarantee that the number of concurrently active handler - # flow materializations will never exceed the limit for the reason that it is impossible to reliably - # detect when a materialization has ended. - max-connections = 1024 - - # The maximum number of requests that are accepted (and dispatched to - # the application) on one single connection before the first request - # has to be completed. - # Incoming requests that would cause the pipelining limit to be exceeded - # are not read from the connections socket so as to build up "back-pressure" - # to the client via TCP flow control. - # A setting of 1 disables HTTP pipelining, since only one request per - # connection can be "open" (i.e. being processed by the application) at any - # time. Set to higher values to enable HTTP pipelining. - # This value must be > 0 and <= 1024. - pipelining-limit = 1 - - parsing { - max-content-length = 512M - - # Defines the maximum length of the URL - # Set to 10k because Sparql queries for the extended search v2 are submitted as segments of the URL - max-uri-length = 10k - } - } - - client { - # The time period within which the TCP connecting process must be completed. - connecting-timeout = 479999 ms // 480 s - - # The time after which an idle connection will be automatically closed. - # Set to `infinite` to completely disable idle timeouts. - idle-timeout = 479999 ms // 480 s - - parsing { - max-chunk-size = 2m - max-response-reason-length = 1024 - } - } - - host-connection-pool { - # The maximum number of parallel connections that a connection pool to a - # single host endpoint is allowed to establish. Must be greater than zero. - max-connections = 15 - - # The minimum number of parallel connections that a pool should keep alive ("hot"). - # If the number of connections is falling below the given threshold, new ones are being spawned. - # You can use this setting to build a hot pool of "always on" connections. - # Default is 0, meaning there might be no active connection at given moment. - # Keep in mind that `min-connections` should be smaller than `max-connections` or equal - min-connections = 0 - - # The maximum number of times failed requests are attempted again, - # (if the request can be safely retried) before giving up and returning an error. - # Set to zero to completely disable request retries. - max-retries = 3 - - # The maximum number of open requests accepted into the pool across all - # materializations of any of its client flows. - # Protects against (accidentally) overloading a single pool with too many client flow materializations. - # Note that with N concurrent materializations the max number of open request in the pool - # will never exceed N * max-connections * pipelining-limit. - # Must be a power of 2 and > 0! - max-open-requests = 64 - - # The maximum number of requests that are dispatched to the target host in - # batch-mode across a single connection (HTTP pipelining). - # A setting of 1 disables HTTP pipelining, since only one request per - # connection can be "in flight" at any time. - # Set to higher values to enable HTTP pipelining. - # This value must be > 0. - # (Note that, independently of this setting, pipelining will never be done - # on a connection that still has a non-idempotent request in flight. - # - # Before increasing this value, make sure you understand the effects of head-of-line blocking. - # Using a connection pool, a request may be issued on a connection where a previous - # long-running request hasn't finished yet. The response to the pipelined requests may then be stuck - # behind the response of the long-running previous requests on the server. This may introduce an - # unwanted "coupling" of run time between otherwise unrelated requests. - # - # See http://tools.ietf.org/html/rfc7230#section-6.3.2 for more info.) - pipelining-limit = 1 - - # The time after which an idle connection pool (without pending requests) - # will automatically terminate itself. Set to `infinite` to completely disable idle timeouts. - idle-timeout = 30 s - - # Modify to tweak client settings for host connection pools only. - # - # IMPORTANT: - # Please note that this section mirrors `pekko.http.client` however is used only for pool-based APIs, - # such as `Http().superPool` or `Http().singleRequest`. - client = { - # no overrides, see `pekko.http.client` for used values - } - } - } -} - - -// all responder actors should run on this dispatcher -knora-actor-dispatcher { - type = Dispatcher - - executor = "fork-join-executor" - # Configuration for the fork join pool - fork-join-executor { - parallelism-min = 8 - parallelism-factor = 2.0 - parallelism-max = 32 - } - - throughput = 5 -} - -// any futures or blocking code should run on this dispatcher -knora-blocking-dispatcher { - type = Dispatcher - - executor = "thread-pool-executor" - - thread-pool-executor { - core-pool-size-min = 8 - core-pool-size-factor = 2.0 - core-pool-size-max = 32 - } - - throughput = 1 -} - -pekko-http-cors { - - # If enabled, allow generic requests (that are outside the scope of the specification) - # to pass through the directive. Else, strict CORS filtering is applied and any - # invalid request will be rejected. - allow-generic-http-requests = yes - - # Indicates whether the resource supports user credentials. If enabled, the header - # `Access-Control-Allow-Credentials` is set in the response, indicating that the - # actual request can include user credentials. Examples of user credentials are: - # cookies, HTTP authentication or client-side certificates. - allow-credentials = yes - - # List of origins that the CORS filter must allow. Can also be set to `*` to allow - # access to the resource from any origin. Controls the content of the - # `Access-Control-Allow-Origin` response header: if parameter is `*` and credentials - # are not allowed, a `*` is set in `Access-Control-Allow-Origin`. Otherwise, the - # origins given in the `Origin` request header are echoed. - # - # Hostname starting with `*.` will match any sub-domain. - # The scheme and the port are always strictly matched. - # - # The actual or preflight request is rejected if any of the origins from the request - # is not allowed. - allowed-origins = "*" - - # List of request headers that can be used when making an actual request. Controls - # the content of the `Access-Control-Allow-Headers` header in a preflight response: - # if parameter is `*`, the headers from `Access-Control-Request-Headers` are echoed. - # Otherwise the parameter list is returned as part of the header. - allowed-headers = "*" - - # List of methods that can be used when making an actual request. The list is - # returned as part of the `Access-Control-Allow-Methods` preflight response header. - # - # The preflight request will be rejected if the `Access-Control-Request-Method` - # header's method is not part of the list. - allowed-methods = ["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"] - - # List of headers (other than simple response headers) that browsers are allowed to access. - # If not empty, this list is returned as part of the `Access-Control-Expose-Headers` - # header in the actual response. - exposed-headers = ["Server"] - - # When set, the amount of seconds the browser is allowed to cache the results of a preflight request. - # This value is returned as part of the `Access-Control-Max-Age` preflight response header. - # If `null`, the header is not added to the preflight response. - max-age = 1800 seconds -} - app { print-extended-config = false // If true, an extended list of configuration parameters will be printed out at startup. print-extended-config = ${?KNORA_WEBAPI_PRINT_EXTENDED_CONFIG} - // default ask timeout. can be same or lower then pekko.http.server.request-timeout. + // default ask timeout. default-timeout = 120 minutes // a timeout here should never happen - // If true, log all messages sent from and received by routes. Since messages are logged at DEBUG level, you will - // need to set loglevel = "DEBUG" in the pekko section of this file, and in logback.xml. + // If true, log all messages sent from and received. dump-messages = false show-internal-errors = true // If true, clients will see error messages from internal errors. Useful for debugging. If false, those error messages will appear only in the log. @@ -410,13 +174,13 @@ app { host = "localhost" host = ${?KNORA_WEBAPI_TRIPLESTORE_HOST} - // timeout for triplestore queries. can be same or lower then pekko.http.server.request-timeout. + // timeout for triplestore queries. query-timeout = 20 seconds - // timeout for triplestore queries for maintenance actions. can be same or lower then pekko.http.server.request-timeout. + // timeout for triplestore queries for maintenance actions. maintenance-timeout = 120 seconds - // timeout for Gravsearch queries. can be same or lower then pekko.http.server.request-timeout. + // timeout for Gravsearch queries. gravsearch-timeout = 120 seconds fuseki { diff --git a/webapi/src/main/scala/org/knora/webapi/Main.scala b/webapi/src/main/scala/org/knora/webapi/Main.scala index 0a874a8503a..4d64c12b85d 100644 --- a/webapi/src/main/scala/org/knora/webapi/Main.scala +++ b/webapi/src/main/scala/org/knora/webapi/Main.scala @@ -7,10 +7,11 @@ package org.knora.webapi import zio.* +import org.knora.webapi.config.AppConfig.AppConfigurations +import org.knora.webapi.config.KnoraApi import org.knora.webapi.core.* -import org.knora.webapi.core.Db.DbInitEnv +import org.knora.webapi.core.LayersLive.Environment import org.knora.webapi.slice.infrastructure.MetricsServer -import org.knora.webapi.slice.infrastructure.MetricsServer.MetricsServerEnv object Main extends ZIOApp { @@ -19,20 +20,17 @@ object Main extends ZIOApp { /** * The `Environment` that we require to exist at startup. */ - override type Environment = LayersLive.ApplicationEnvironment + override type Environment = AppConfigurations & LayersLive.Environment /** * The layers provided to the application. */ - override def bootstrap: ZLayer[Any, Nothing, Environment] = LayersLive.bootstrap + override def bootstrap: ULayer[Environment] = LayersLive.bootstrap /** * Entrypoint of our Application */ - override def run: ZIO[Environment & ZIOAppArgs & Scope, Any, Any] = app + override def run: ZIO[Environment & ZIOAppArgs & Scope, Any, Any] = + (Db.init *> DspApiServer.startup *> MetricsServer.make).provideSomeAuto(DspApiServer.layer) - /** - * The application logic. - */ - def app: ZIO[Environment, Throwable, Unit] = Db.init *> MetricsServer.make } 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 8b7a7638054..2b61bfac35f 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -13,8 +13,6 @@ import zio.config.typesafe.* import java.time.Duration -import org.knora.webapi.core.LayersLive - /** * Represents the configuration as defined in application.conf. */ @@ -187,7 +185,8 @@ final case class OpenTelemetryConfig( ) object AppConfig { - type AppConfigurations = LayersLive.Config & InstrumentationServerConfig & JwtConfig & DspIngestConfig + type AppConfigurations = AppConfig & DspIngestConfig & InstrumentationServerConfig & KnoraApi & Sipi & Triplestore & + Features & GraphRoute & JwtConfig & OpenTelemetryConfig val parseConfig: UIO[AppConfig] = { val descriptor = deriveConfig[AppConfig].mapKey(toKebabCase) diff --git a/webapi/src/main/scala/org/knora/webapi/core/DspApiServer.scala b/webapi/src/main/scala/org/knora/webapi/core/DspApiServer.scala new file mode 100644 index 00000000000..900297bb354 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/core/DspApiServer.scala @@ -0,0 +1,61 @@ +/* + * 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.core + +import sttp.model.Method.* +import sttp.tapir.server.interceptor.cors.CORSConfig +import sttp.tapir.server.interceptor.cors.CORSInterceptor +import sttp.tapir.server.metrics.zio.ZioMetrics +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import sttp.tapir.server.ziohttp.ZioHttpServerOptions +import zio.* +import zio.http.* +import zio.http.Server.Config.ResponseCompressionConfig + +import org.knora.webapi.config.KnoraApi +import org.knora.webapi.routing.Endpoints + +final case class DspApiServer(server: Server, endpoints: Endpoints, c: KnoraApi) { + + private val serverOptions: ZioHttpServerOptions[Any] = + ZioHttpServerOptions.customiseInterceptors + .corsInterceptor( + CORSInterceptor.customOrThrow( + CORSConfig.default.allowCredentials + .allowMethods(GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH) + .allowMatchingOrigins(_ => true) + .exposeAllHeaders + .maxAge(30.minutes.asScala), + ), + ) + .metricsInterceptor(ZioMetrics.default[Task]().metricsInterceptor()) + .options + + def startup(): UIO[Unit] = for { + _ <- ZIO.logInfo("Starting DSP API server...") + app = ZioHttpInterpreter(serverOptions).toHttp(endpoints.serverEndpoints) + actualPort <- Server.install(app).provide(ZLayer.succeed(server)): @annotation.nowarn + _ <- ZIO.logInfo(s"API available at http://${c.externalHost}:$actualPort/version") + } yield () +} + +object DspApiServer { + + def startup: RIO[DspApiServer, Unit] = ZIO.serviceWithZIO[DspApiServer](_.startup()) + + private val serverLayer = ZLayer + .service[KnoraApi] + .flatMap(cfg => + Server + .defaultWith( + _.binding(cfg.get.internalHost, cfg.get.internalPort).enableRequestStreaming + .responseCompression(ResponseCompressionConfig.default), + ), + ) + .orDie + + val layer = serverLayer >>> ZLayer.derive[DspApiServer] +} diff --git a/webapi/src/main/scala/org/knora/webapi/core/HttpServer.scala b/webapi/src/main/scala/org/knora/webapi/core/HttpServer.scala deleted file mode 100644 index 1cee558ceba..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/core/HttpServer.scala +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.core - -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.http.scaladsl.Http -import zio.* - -import org.knora.webapi.config.AppConfig -import org.knora.webapi.routing.ApiRoutes - -/** - * The Akka based HTTP server - */ -trait HttpServer { - val serverBinding: Http.ServerBinding -} - -object HttpServer { - val layer: ZLayer[ActorSystem & AppConfig & ApiRoutes, Nothing, HttpServer] = - ZLayer.scoped { - for { - as <- ZIO.service[ActorSystem] - config <- ZIO.service[AppConfig] - apiRoutes <- ZIO.service[ApiRoutes] - binding <- { - implicit val system: ActorSystem = as - - ZIO.acquireRelease { - ZIO - .fromFuture(_ => - Http().newServerAt(config.knoraApi.internalHost, config.knoraApi.internalPort).bind(apiRoutes.routes), - ) - .zipLeft(ZIO.logInfo(">>> Acquire HTTP Server <<<")) - .orDie - } { serverBinding => - ZIO - .fromFuture(_ => - serverBinding.terminate( - new scala.concurrent.duration.FiniteDuration(1, scala.concurrent.duration.MILLISECONDS), - ), - ) - .zipLeft(ZIO.logInfo(">>> Release HTTP Server <<<")) - .orDie - } - } - } yield HttpServerImpl(binding) - } - - private final case class HttpServerImpl(binding: Http.ServerBinding) extends HttpServer { self => - val serverBinding: Http.ServerBinding = self.binding - } -} 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 346b863b6ba..72ed29d75d5 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -5,10 +5,10 @@ package org.knora.webapi.core -import org.apache.pekko.actor.ActorSystem import zio.* import org.knora.webapi.config.AppConfig +import org.knora.webapi.config.AppConfig.AppConfigurations import org.knora.webapi.config.DspIngestConfig import org.knora.webapi.config.Features import org.knora.webapi.config.GraphRoute @@ -17,7 +17,6 @@ import org.knora.webapi.config.KnoraApi import org.knora.webapi.config.OpenTelemetryConfig import org.knora.webapi.config.Sipi import org.knora.webapi.config.Triplestore -import org.knora.webapi.core.Db.DbInitEnv import org.knora.webapi.messages.util.* import org.knora.webapi.messages.util.search.QueryTraverser import org.knora.webapi.messages.util.search.gravsearch.transformers.OntologyInferencer @@ -32,6 +31,7 @@ import org.knora.webapi.routing.* import org.knora.webapi.slice.admin.AdminModule import org.knora.webapi.slice.admin.api.* import org.knora.webapi.slice.admin.domain.service.* +import org.knora.webapi.slice.api.v2.ApiV2ServerEndpoints import org.knora.webapi.slice.common.ApiComplexV2JsonLdRequestParser import org.knora.webapi.slice.common.CommonModule import org.knora.webapi.slice.common.CommonModule.Provided @@ -41,11 +41,10 @@ import org.knora.webapi.slice.common.service.IriConverter import org.knora.webapi.slice.infrastructure.InfrastructureModule import org.knora.webapi.slice.infrastructure.InfrastructureModule.Provided import org.knora.webapi.slice.infrastructure.JwtService -import org.knora.webapi.slice.infrastructure.MetricsServer.MetricsServerEnv import org.knora.webapi.slice.infrastructure.OpenTelemetry import org.knora.webapi.slice.infrastructure.api.ManagementEndpoints import org.knora.webapi.slice.infrastructure.api.ManagementRestService -import org.knora.webapi.slice.infrastructure.api.ManagementRoutes +import org.knora.webapi.slice.infrastructure.api.ManagementServerEndpoints import org.knora.webapi.slice.lists.api.ListsApiModule import org.knora.webapi.slice.ontology.OntologyModule import org.knora.webapi.slice.ontology.OntologyModule.Provided @@ -55,16 +54,15 @@ import org.knora.webapi.slice.ontology.domain.service.OntologyRepo import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.slice.resources.ResourcesModule import org.knora.webapi.slice.resources.api.ResourcesApiModule -import org.knora.webapi.slice.resources.api.ResourcesApiRoutes +import org.knora.webapi.slice.resources.api.ResourcesApiServerEndpoints import org.knora.webapi.slice.resources.repo.service.ResourcesRepo import org.knora.webapi.slice.resources.repo.service.ResourcesRepoLive -import org.knora.webapi.slice.search.api.SearchApiRoutes import org.knora.webapi.slice.search.api.SearchEndpoints +import org.knora.webapi.slice.search.api.SearchServerEndpoints import org.knora.webapi.slice.security.SecurityModule import org.knora.webapi.slice.security.api.AuthenticationApiModule 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.store.iiif.IIIFRequestMessageHandler import org.knora.webapi.store.iiif.IIIFRequestMessageHandlerLive @@ -75,70 +73,14 @@ import org.knora.webapi.util.Logger object LayersLive { self => - /* - * This layer composition is done really happhazardly. - * This can certainly be improved a lot. - * However, this brings the layers ependency graph from ~12.7k to ~2.9k nodes. - * (By that I'm refering to the text output one gets when enabling `ZLayer.Debug.mermaid`.) - * It is unclear how closely this relates to the actual graph construction code, - * but I expect it will prevent the `class too big` error that we have seen in the past. - * It seems to have little or no impact on compile time. - */ - - private type ConfigDependencies = Any - private type ConfigProvided = AppConfig.AppConfigurations - - private type IntermediateDependencies1 = AppConfig & Triplestore & Features & DspIngestConfig & JwtConfig - private type IntermediateProvided1 = CommonModule.Provided & IriService & OntologyModule.Provided & - InfrastructureModule.Provided - - private type IntermediateDependencies3 = IntermediateProvided1 & Features & DspIngestConfig & JwtConfig & - DspIngestClient & PredicateObjectMapper & AppConfig - private type IntermediateProvided3 = AdminModule.Provided & IntermediateProvided1 - - type ApplicationEnvironment = DbInitEnv & MetricsServerEnv - - private val configLayer: ZLayer[self.ConfigDependencies, Nothing, self.ConfigProvided] = - Logger.fromEnv() >>> AppConfig.layer - - val intermediateLayers1: ZLayer[self.IntermediateDependencies1, Nothing, self.IntermediateProvided1] = - ZLayer.makeSome[self.IntermediateDependencies1, self.IntermediateProvided1]( - CommonModule.layer, - IriService.layer, - OntologyModule.layer, - InfrastructureModule.layer, - ) - - val intermediateLayers2 - : ZLayer[JwtService & DspIngestConfig & IriConverter, Nothing, DspIngestClient & PredicateObjectMapper] = - ZLayer.makeSome[JwtService & DspIngestConfig & IriConverter, DspIngestClient & PredicateObjectMapper]( - DspIngestClient.layer, - PredicateObjectMapper.layer, - ) - - val intermediateLayers3 - : ZLayer[self.IntermediateDependencies3, Nothing, self.IntermediateProvided3 & DspIngestConfig & JwtConfig] = - ZLayer.makeSome[self.IntermediateDependencies3, self.IntermediateProvided3 & DspIngestConfig & JwtConfig]( - AdminModule.layer, - ) - - private val intermediateLayersAll = - configLayer >+> intermediateLayers1 >+> intermediateLayers2 >+> intermediateLayers3 - - val bootstrap: ZLayer[Any, Nothing, ApplicationEnvironment] = - intermediateLayersAll >+> LayersLive.remainingLayer - /** * The `Environment` that we require to exist at startup. */ type Environment = // format: off - ActorSystem & - AdminApiEndpoints & AdminApiModule.Provided & AdminModule.Provided & ApiComplexV2JsonLdRequestParser & - ApiRoutes & ApiV2Endpoints & AssetPermissionsResponder & AuthenticationApiModule.Provided & @@ -146,9 +88,8 @@ object LayersLive { self => CardinalityHandler & CommonModule.Provided & ConstructResponseUtilV2 & - OntologyModule.Provided & DefaultObjectAccessPermissionService & - HttpServer & + Endpoints & IIIFRequestMessageHandler & InfrastructureModule.Provided & ListsApiModule.Provided & @@ -156,6 +97,7 @@ object LayersLive { self => MessageRelay & OntologyApiModule.Provided & OntologyInferencer & + OntologyModule.Provided & OntologyResponderV2 & PermissionUtilADM & PermissionsResponder & @@ -164,16 +106,16 @@ object LayersLive { self => ProjectImportService & RepositoryUpdater & ResourceUtilV2 & - ResourcesApiRoutes & - ResourcesResponderV2 & + ResourcesApiServerEndpoints & ResourcesRepo & - SecurityModule.Provided & - SearchApiRoutes & + ResourcesResponderV2 & SearchResponderV2Module.Provided & + SearchServerEndpoints & + SecurityModule.Provided & SecurityModule.Provided & ShaclApiModule.Provided & - ShaclModule.Provided & ShaclEndpoints & + ShaclModule.Provided & SipiService & StandoffResponderV2 & StandoffTagUtilV2 & @@ -181,39 +123,44 @@ object LayersLive { self => ValuesResponderV2 // format: on - type Config = AppConfig & Features & GraphRoute & KnoraApi & OpenTelemetryConfig & Sipi & Triplestore - - val remainingLayer = + val remainingLayer: URLayer[AppConfigurations, Environment] = ZLayer.makeSome[ - Config & IntermediateProvided1 & IntermediateProvided3 & DspIngestClient & PredicateObjectMapper, + AppConfig & DspIngestConfig & KnoraApi & Sipi & Triplestore & Features & GraphRoute & JwtConfig & + OpenTelemetryConfig, self.Environment, ]( + // ZLayer.Debug.mermaid, AdminApiModule.layer, + AdminModule.layer, ApiComplexV2JsonLdRequestParser.layer, - ApiRoutes.layer, ApiV2Endpoints.layer, + ApiV2ServerEndpoints.layer, AssetPermissionsResponder.layer, AuthenticationApiModule.layer, AuthorizationRestService.layer, BaseEndpoints.layer, CardinalityHandler.layer, + CommonModule.layer, ConstructResponseUtilV2.layer, - HandlerMapper.layer, - HttpServer.layer, + DspIngestClient.layer, + Endpoints.layer, IIIFRequestMessageHandlerLive.layer, + InfrastructureModule.layer, + IriService.layer, KnoraResponseRenderer.layer, ListsApiModule.layer, ListsResponder.layer, ManagementEndpoints.layer, ManagementRestService.layer, - ManagementRoutes.layer, + ManagementServerEndpoints.layer, MessageRelayLive.layer, OntologyApiModule.layer, + OntologyModule.layer, OntologyResponderV2.layer, OpenTelemetry.layer, - PekkoActorSystem.layer, PermissionUtilADMLive.layer, PermissionsResponder.layer, + PredicateObjectMapper.layer, ProjectExportServiceLive.layer, ProjectExportStorageServiceLive.layer, ProjectImportService.layer, @@ -223,9 +170,9 @@ object LayersLive { self => ResourcesModule.layer, ResourcesRepoLive.layer, ResourcesResponderV2.layer, - SearchApiRoutes.layer, SearchEndpoints.layer, SearchResponderV2Module.layer, + SearchServerEndpoints.layer, SecurityModule.layer, ShaclApiModule.layer, ShaclModule.layer, @@ -233,8 +180,10 @@ object LayersLive { self => StandoffResponderV2.layer, StandoffTagUtilV2Live.layer, State.layer, - TapirToPekkoInterpreter.layer, ValuesResponderV2.layer, - // ZLayer.Debug.mermaid, ) + + private val loggerAndConfig: ULayer[AppConfigurations] = Logger.fromEnv() >>> AppConfig.layer + + val bootstrap: ULayer[AppConfigurations & Environment] = loggerAndConfig >+> LayersLive.remainingLayer } diff --git a/webapi/src/main/scala/org/knora/webapi/core/PekkoActorSystem.scala b/webapi/src/main/scala/org/knora/webapi/core/PekkoActorSystem.scala deleted file mode 100644 index 70c30c2459e..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/core/PekkoActorSystem.scala +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.core - -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.actor.Terminated -import zio.* - -import scala.concurrent.ExecutionContext - -object PekkoActorSystem { - - private def acquire(executionContext: ExecutionContext): UIO[ActorSystem] = - ZIO - .attempt(ActorSystem("webapi", None, None, Some(executionContext))) - .retry(Schedule.exponential(1.second) && Schedule.recurs(3)) - .tapError(error => ZIO.logError(s"Failed to initialize Actor System: ${error.getMessage}")) - .orDie <* - ZIO.logInfo(">>> Acquire Actor System <<<") - - private def release(system: ActorSystem): UIO[Terminated] = - ZIO.fromFuture(_ => system.terminate()).orDie <* ZIO.logInfo(">>> Release Actor System <<<") - - val layer: ULayer[ActorSystem] = ZLayer.scoped( - for { - context <- ZIO.executor.map(_.asExecutionContext) - system <- ZIO.acquireRelease(acquire(context))(release) - } yield system, - ) -} diff --git a/webapi/src/main/scala/org/knora/webapi/http/version/ServerVersion.scala b/webapi/src/main/scala/org/knora/webapi/http/version/ServerVersion.scala index 55f42e6b6af..39a3c5dab5d 100644 --- a/webapi/src/main/scala/org/knora/webapi/http/version/ServerVersion.scala +++ b/webapi/src/main/scala/org/knora/webapi/http/version/ServerVersion.scala @@ -5,23 +5,6 @@ package org.knora.webapi.http.version -import org.apache.pekko - -import pekko.http.scaladsl.model.headers.Server -import pekko.http.scaladsl.server.Directives.respondWithHeader -import pekko.http.scaladsl.server.Route - -/** - * This object provides methods that can be used to add the [[Server]] header - * to an [[pekko.http.scaladsl.model.HttpResponse]]. - */ object ServerVersion { - - private val ApiNameAndVersion = s"${BuildInfo.name}/${BuildInfo.version}" - - def serverVersionHeader: Server = Server(products = ApiNameAndVersion) - - def addServerHeader(route: Route): Route = respondWithHeader(serverVersionHeader) { - route - } + val ApiNameAndVersion = s"${BuildInfo.name}/${BuildInfo.version}" } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala index a5aebce692b..b073dd736b7 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/rdf/RdfFormatUtil.scala @@ -7,6 +7,7 @@ package org.knora.webapi.messages.util.rdf import org.apache.jena import sttp.model.MediaType +import sttp.tapir.CodecFormat import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -28,9 +29,7 @@ import org.knora.webapi.SchemaOptions /** * A trait for supported RDF formats. */ -sealed trait RdfFormat { - def mediaType: MediaType -} +sealed trait RdfFormat extends CodecFormat /** * A trait for formats other than JSON-LD. diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponder.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponder.scala index b0b2ea8cdaa..ad2a24ae2c0 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponder.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponder.scala @@ -60,8 +60,8 @@ final case class ListsResponder( * (as lists can be very large), we only return the head of the list, i.e. the root node without * any children. * - * @param projectIri [[Some(ProjectIri)]] if the project for which lists are to be queried. - * [[None]] if all lists are to be queried. + * @param iriShortcode [[Some(ProjectIri|Shortcode)]] if the project for which lists are to be queried. + * [[None]] if all lists are to be queried. * @return a [[ListsGetResponseADM]]. */ def getLists(iriShortcode: Option[Either[ProjectIri, Shortcode]]): Task[ListsGetResponseADM] = @@ -143,21 +143,23 @@ final case class ListsResponder( * @param nodeIri the Iri if the required node. * @return a [[ListItemGetResponseADM]]. */ - def listGetRequestADM(nodeIri: IRI): Task[ListItemGetResponseADM] = { + def listGetRequestADM(nodeIri: ListIri): Task[ListItemGetResponseADM] = { def getNodeADM(childNode: ListChildNodeADM): Task[ListNodeGetResponseADM] = for { - maybeNodeInfo <- listNodeInfoGetADM(nodeIri = nodeIri) + maybeNodeInfo <- listNodeInfoGetADM(nodeIri.value) nodeInfo <- maybeNodeInfo match { case Some(childNodeInfo: ListChildNodeInfoADM) => ZIO.succeed(childNodeInfo) case _ => ZIO.fail(NotFoundException(s"Information not found for node '$nodeIri'")) } } yield ListNodeGetResponseADM(NodeADM(nodeInfo, childNode.children)) - ZIO.ifZIO(rootNodeByIriExists(nodeIri))( - listGetADM(nodeIri).someOrFail(NotFoundException(s"List '$nodeIri' not found")).map(ListGetResponseADM.apply), + ZIO.ifZIO(rootNodeByIriExists(nodeIri.value))( + listGetADM(nodeIri.value) + .someOrFail(NotFoundException(s"List '$nodeIri' not found")) + .map(ListGetResponseADM.apply), for { - maybeNode <- listNodeGetADM(nodeIri, shallow = true) + maybeNode <- listNodeGetADM(nodeIri.value, shallow = true) entireNode <- maybeNode match { // make sure that it is a child node diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index f5ae07947e0..a29b3001f2a 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -346,6 +346,13 @@ final case class OntologyResponderV2( userLang = Some(requestingUser.lang).filter(_ => !allLanguages) } yield ontology.copy(userLang = userLang) + def getPropertyFromOntologyV2( + propertyIri: PropertyIri, + allLanguages: Boolean, + requestingUser: User, + ): Task[ReadOntologyV2] = + getPropertyDefinitionsFromOntologyV2(Set(propertyIri.smartIri), allLanguages, requestingUser) + /** * Requests information about properties in a single ontology. * @@ -1979,8 +1986,7 @@ final case class OntologyResponderV2( .someOrFail(NotFoundException(s"Class ${classIri.toComplexSchema} not found")) hasComment = classToUpdate.entityInfoContent.predicates.contains(OntologyConstants.Rdfs.Comment.toSmartIri) _ <- IriLocker.runWithIriLock(apiRequestID, ONTOLOGY_CACHE_LOCK_IRI)(deleteCommentTask).when(hasComment) - response <- - ontologyCacheHelpers.getClasses(classIris = Seq(classIri), allLanguages = true, requestingUser = requestingUser) + response <- ontologyCacheHelpers.getClassAsReadOntologyV2(classIri, allLanguages = true, requestingUser) } yield response } } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala deleted file mode 100644 index 90e1f8e858f..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.routing - -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.http.cors.scaladsl.CorsDirectives -import org.apache.pekko.http.cors.scaladsl.settings.CorsSettings -import org.apache.pekko.http.scaladsl.model.HttpMethods.* -import org.apache.pekko.http.scaladsl.server.Directives.* -import org.apache.pekko.http.scaladsl.server.Route -import zio.* - -import org.knora.webapi.http.version.ServerVersion -import org.knora.webapi.routing -import org.knora.webapi.slice.admin.api.AdminApiRoutes -import org.knora.webapi.slice.infrastructure.api.ManagementRoutes -import org.knora.webapi.slice.lists.api.ListsApiV2Routes -import org.knora.webapi.slice.ontology.api.OntologiesApiRoutes -import org.knora.webapi.slice.resources.api.ResourceInfoRoutes -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 - -/** - * All routes composed together and CORS activated based on the - * the configuration in application.conf (pekko-http-cors). - * - * ALL requests go through each of the routes in ORDER. - * The FIRST matching route is used for handling a request. - */ -final case class ApiRoutes( - adminApiRoutes: AdminApiRoutes, - authenticationApiRoutes: AuthenticationApiRoutes, - listsApiV2Routes: ListsApiV2Routes, - resourceInfoRoutes: ResourceInfoRoutes, - resourcesApiRoutes: ResourcesApiRoutes, - searchApiRoutes: SearchApiRoutes, - shaclApiRoutes: ShaclApiRoutes, - managementRoutes: ManagementRoutes, - ontologiesRoutes: OntologiesApiRoutes, - system: ActorSystem, -) { - val routes: Route = - ServerVersion.addServerHeader { - CorsDirectives.cors( - CorsSettings(system).withAllowedMethods(List(GET, PUT, POST, DELETE, PATCH, HEAD, OPTIONS)), - ) { - (adminApiRoutes.routes ++ - authenticationApiRoutes.routes ++ - listsApiV2Routes.routes ++ - managementRoutes.routes ++ - ontologiesRoutes.routes ++ - resourceInfoRoutes.routes ++ - resourcesApiRoutes.routes ++ - searchApiRoutes.routes ++ - shaclApiRoutes.routes).reduce(_ ~ _) - } - } -} -object ApiRoutes { - val layer = ZLayer.derive[ApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/Endpoints.scala b/webapi/src/main/scala/org/knora/webapi/routing/Endpoints.scala new file mode 100644 index 00000000000..5daca3fee4f --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/Endpoints.scala @@ -0,0 +1,32 @@ +/* + * 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.routing + +import sttp.capabilities.zio.ZioStreams +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.routing +import org.knora.webapi.slice.admin.api.AdminApiServerEndpoints +import org.knora.webapi.slice.api.v2.ApiV2ServerEndpoints +import org.knora.webapi.slice.infrastructure.api.ManagementServerEndpoints +import org.knora.webapi.slice.shacl.api.ShaclServerEndpoints + +final case class Endpoints( + private val adminApiServerEndpoints: AdminApiServerEndpoints, + private val apiV2ServerEndpoints: ApiV2ServerEndpoints, + private val managementServerEndpoints: ManagementServerEndpoints, + private val shaclServerEndpoints: ShaclServerEndpoints, +) { + val serverEndpoints: List[ZServerEndpoint[Any, ZioStreams]] = + adminApiServerEndpoints.serverEndpoints ++ + apiV2ServerEndpoints.serverEndpoints ++ + managementServerEndpoints.serverEndpoints ++ + shaclServerEndpoints.serverEndpoints +} +object Endpoints { + val layer = ZLayer.derive[Endpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiEndpoints.scala deleted file mode 100644 index 4473951aadf..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiEndpoints.scala +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.admin.api - -import sttp.tapir.AnyEndpoint -import zio.ZLayer - -final case class AdminApiEndpoints( - private val adminListsEndpoints: AdminListsEndpoints, - private val filesEndpoints: FilesEndpoints, - private val groupsEndpoints: GroupsEndpoints, - private val maintenanceEndpoints: MaintenanceEndpoints, - private val permissionsEndpoints: PermissionsEndpoints, - private val projectsEndpoints: ProjectsEndpoints, - private val projectsLegalInfoEndpoint: ProjectsLegalInfoEndpoints, - private val storeEndpoints: StoreEndpoints, - private val usersEndpoints: UsersEndpoints, -) { - - val endpoints: Seq[AnyEndpoint] = - groupsEndpoints.endpoints ++ - adminListsEndpoints.endpoints ++ - maintenanceEndpoints.endpoints ++ - permissionsEndpoints.endpoints ++ - projectsLegalInfoEndpoint.endpoints ++ - projectsEndpoints.endpoints ++ - storeEndpoints.endpoints ++ - usersEndpoints.endpoints ++ - filesEndpoints.endpoints -} - -object AdminApiEndpoints { - val layer = ZLayer.derive[AdminApiEndpoints] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala index 2afff367492..b4925e7799a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiModule.scala @@ -51,7 +51,6 @@ object AdminApiModule { self => CacheManager & Features & GroupService & - HandlerMapper & KnoraGroupService & KnoraProjectService & KnoraResponseRenderer & @@ -67,15 +66,13 @@ object AdminApiModule { self => ProjectExportService & ProjectImportService & ProjectService & - TapirToPekkoInterpreter & TriplestoreService & UserService // format: on type Provided = // format: off - AdminApiEndpoints & - AdminApiRoutes & + AdminApiServerEndpoints & // the `*RestService`s are only exposed for the integration tests GroupRestService & PermissionRestService & @@ -85,33 +82,32 @@ object AdminApiModule { self => val layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( - AdminApiEndpoints.layer, - AdminApiRoutes.layer, + AdminApiServerEndpoints.layer, AdminListRestService.layer, AdminListsEndpoints.layer, - AdminListsEndpointsHandlers.layer, + AdminListsServerEndpoints.layer, FilesEndpoints.layer, - FilesEndpointsHandler.layer, + FilesServerEndpoints.layer, GroupRestService.layer, GroupsEndpoints.layer, - GroupsEndpointsHandler.layer, + GroupsServerEndpoints.layer, MaintenanceEndpoints.layer, - MaintenanceEndpointsHandlers.layer, MaintenanceRestService.layer, + MaintenanceServerEndpoints.layer, PermissionRestService.layer, PermissionsEndpoints.layer, - PermissionsEndpointsHandlers.layer, + PermissionsServerEndpoints.layer, ProjectRestService.layer, ProjectsEndpoints.layer, - ProjectsEndpointsHandler.layer, ProjectsLegalInfoEndpoints.layer, - ProjectsLegalInfoEndpointsHandler.layer, ProjectsLegalInfoRestService.layer, + ProjectsLegalInfoServerEndpoints.layer, + ProjectsServerEndpoints.layer, StoreEndpoints.layer, - StoreEndpointsHandler.layer, StoreRestService.layer, + StoreServerEndpoints.layer, UserRestService.layer, UsersEndpoints.layer, - UsersEndpointsHandler.layer, + UsersServerEndpoints.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiRoutes.scala deleted file mode 100644 index 1d813404632..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiRoutes.scala +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.admin.api - -import org.apache.pekko.http.scaladsl.server.Route -import zio.ZLayer - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class AdminApiRoutes( - private val adminLists: AdminListsEndpointsHandlers, - private val filesEndpoints: FilesEndpointsHandler, - private val groups: GroupsEndpointsHandler, - private val maintenance: MaintenanceEndpointsHandlers, - private val permissions: PermissionsEndpointsHandlers, - private val project: ProjectsEndpointsHandler, - private val projectLegalInfo: ProjectsLegalInfoEndpointsHandler, - private val storeEndpoints: StoreEndpointsHandler, - private val tapirToPekko: TapirToPekkoInterpreter, - private val users: UsersEndpointsHandler, -) { - - private val handlers = - filesEndpoints.allHandlers ++ - groups.allHandlers ++ - adminLists.allHandlers ++ - maintenance.allHandlers ++ - permissions.allHanders ++ - projectLegalInfo.allHandlers ++ - project.allHanders ++ - storeEndpoints.allHandlers ++ - users.allHanders - - val routes: Seq[Route] = handlers.map(tapirToPekko.toRoute(_)) -} - -object AdminApiRoutes { - val layer = ZLayer.derive[AdminApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiServerEndpoints.scala new file mode 100644 index 00000000000..1d62685f497 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminApiServerEndpoints.scala @@ -0,0 +1,38 @@ +/* + * 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.admin.api + +import sttp.capabilities.zio.ZioStreams +import sttp.tapir.ztapir.* +import zio.* + +final case class AdminApiServerEndpoints( + private val adminListsServerEndpoints: AdminListsServerEndpoints, + private val filesServerEndpoints: FilesServerEndpoints, + private val groupsServerEndpoints: GroupsServerEndpoints, + private val maintenanceServerEndpoints: MaintenanceServerEndpoints, + private val permissionsServerEndpoints: PermissionsServerEndpoints, + private val projectsServerEndpoints: ProjectsServerEndpoints, + private val projectsLegalInfoServerEndpoints: ProjectsLegalInfoServerEndpoints, + private val storeServerEndpoints: StoreServerEndpoints, + private val usersServerEndpoints: UsersServerEndpoints, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, ZioStreams]] = + filesServerEndpoints.serverEndpoints ++ + groupsServerEndpoints.serverEndpoints ++ + adminListsServerEndpoints.serverEndpoints ++ + maintenanceServerEndpoints.serverEndpoints ++ + permissionsServerEndpoints.serverEndpoints ++ + projectsLegalInfoServerEndpoints.serverEndpoints ++ + projectsServerEndpoints.serverEndpoints ++ + storeServerEndpoints.serverEndpoints ++ + usersServerEndpoints.serverEndpoints +} + +object AdminApiServerEndpoints { + val layer = ZLayer.derive[AdminApiServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListRestService.scala index d17677ff9a4..30ceb83f90d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListRestService.scala @@ -38,7 +38,7 @@ final case class AdminListRestService( def getLists(projectIriOpt: Option[Either[ProjectIri, Shortcode]]): Task[ListsGetResponseADM] = listsResponder.getLists(projectIriOpt) - def listGetRequestADM(iri: ListIri): Task[ListItemGetResponseADM] = listsResponder.listGetRequestADM(iri.value) + def listGetRequestADM(iri: ListIri): Task[ListItemGetResponseADM] = listsResponder.listGetRequestADM(iri) def listNodeInfoGetRequestADM(iri: ListIri): Task[NodeInfoGetResponseADM] = listsResponder.listNodeInfoGetRequestADM(iri.value) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpoints.scala index 5ae3e98e862..cfa53e344d3 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpoints.scala @@ -117,23 +117,6 @@ case class AdminListsEndpoints(baseEndpoints: BaseEndpoints) { val deleteListsComment = baseEndpoints.securedEndpoint.delete .in(base / "comments" / listIriPathVar) .out(jsonBody[ListNodeCommentsDeleteResponseADM]) - - private val secured = - List( - postLists, - postListsChild, - putListsByIriName, - putListsByIriLabels, - putListsByIriComments, - putListsByIriPosition, - putListsByIri, - deleteListsByIri, - deleteListsComment, - ).map(_.endpoint) - - private val public = List(getListsQueryByProjectIriOption, getListsByIri, getListsByIriInfo, getListsCanDeleteByIri) - - val endpoints = (secured ++ public).map(_.tag("Admin Lists")) } object Requests { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpointsHandlers.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpointsHandlers.scala deleted file mode 100644 index 7ac74769d1f..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsEndpointsHandlers.scala +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.admin.api - -import zio.ZIO -import zio.ZLayer - -import org.knora.webapi.messages.admin.responder.listsmessages.CanDeleteListResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.ChildNodeInfoGetResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.ListGetResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.ListItemDeleteResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.ListNodeCommentsDeleteResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.NodeInfoGetResponseADM -import org.knora.webapi.messages.admin.responder.listsmessages.NodePositionChangeResponseADM -import org.knora.webapi.slice.admin.api.Requests.* -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.ListProperties.ListIri -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -final case class AdminListsEndpointsHandlers( - private val adminListsEndpoints: AdminListsEndpoints, - private val restService: AdminListRestService, - private val mapper: HandlerMapper, -) { - - private val public = List( - PublicEndpointHandler(adminListsEndpoints.getListsByIri, restService.listGetRequestADM), - PublicEndpointHandler(adminListsEndpoints.getListsQueryByProjectIriOption, restService.getLists), - PublicEndpointHandler(adminListsEndpoints.getListsByIriInfo, restService.listNodeInfoGetRequestADM), - PublicEndpointHandler(adminListsEndpoints.getListsInfosByIri, restService.listNodeInfoGetRequestADM), - PublicEndpointHandler(adminListsEndpoints.getListsNodesByIri, restService.listNodeInfoGetRequestADM), - PublicEndpointHandler(adminListsEndpoints.getListsCanDeleteByIri, restService.canDeleteListRequestADM), - ).map(mapper.mapPublicEndpointHandler) - - private val secured = List( - SecuredEndpointHandler(adminListsEndpoints.postLists, restService.listCreateRootNode), - SecuredEndpointHandler(adminListsEndpoints.postListsChild, restService.listCreateChildNode), - SecuredEndpointHandler(adminListsEndpoints.putListsByIriName, restService.listChangeName), - SecuredEndpointHandler(adminListsEndpoints.putListsByIriLabels, restService.listChangeLabels), - SecuredEndpointHandler(adminListsEndpoints.putListsByIriComments, restService.listChangeComments), - SecuredEndpointHandler(adminListsEndpoints.putListsByIriPosition, restService.nodePositionChangeRequest), - SecuredEndpointHandler(adminListsEndpoints.putListsByIri, restService.listChange), - SecuredEndpointHandler(adminListsEndpoints.deleteListsByIri, restService.deleteListItemRequestADM), - SecuredEndpointHandler(adminListsEndpoints.deleteListsComment, restService.deleteListNodeCommentsADM), - ).map(mapper.mapSecuredEndpointHandler) - - val allHandlers = public ++ secured -} - -object AdminListsEndpointsHandlers { - val layer = ZLayer.derive[AdminListsEndpointsHandlers] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsServerEndpoints.scala new file mode 100644 index 00000000000..9905d6c5283 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/AdminListsServerEndpoints.scala @@ -0,0 +1,42 @@ +/* + * 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.admin.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.api.Requests.* +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.ListProperties.ListIri +import org.knora.webapi.slice.admin.domain.model.User + +final case class AdminListsServerEndpoints( + private val adminListsEndpoints: AdminListsEndpoints, + private val restService: AdminListRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + adminListsEndpoints.getListsQueryByProjectIriOption.zServerLogic(restService.getLists), + adminListsEndpoints.getListsByIri.zServerLogic(restService.listGetRequestADM), + adminListsEndpoints.getListsByIriInfo.zServerLogic(restService.listNodeInfoGetRequestADM), + adminListsEndpoints.getListsInfosByIri.zServerLogic(restService.listNodeInfoGetRequestADM), + adminListsEndpoints.getListsNodesByIri.zServerLogic(restService.listNodeInfoGetRequestADM), + adminListsEndpoints.getListsCanDeleteByIri.zServerLogic(restService.canDeleteListRequestADM), + adminListsEndpoints.postLists.serverLogic(restService.listCreateRootNode), + adminListsEndpoints.postListsChild.serverLogic(restService.listCreateChildNode), + adminListsEndpoints.putListsByIriName.serverLogic(restService.listChangeName), + adminListsEndpoints.putListsByIriLabels.serverLogic(restService.listChangeLabels), + adminListsEndpoints.putListsByIriComments.serverLogic(restService.listChangeComments), + adminListsEndpoints.putListsByIriPosition.serverLogic(restService.nodePositionChangeRequest), + adminListsEndpoints.putListsByIri.serverLogic(restService.listChange), + adminListsEndpoints.deleteListsByIri.serverLogic(restService.deleteListItemRequestADM), + adminListsEndpoints.deleteListsComment.serverLogic(restService.deleteListNodeCommentsADM), + ).map(_.withTag("Admin Lists")) +} + +object AdminListsServerEndpoints { + val layer = ZLayer.derive[AdminListsServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpoints.scala index c6a341ced4f..504dd61011e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpoints.scala @@ -29,10 +29,6 @@ final case class FilesEndpoints(base: BaseEndpoints) { .description( "Returns the permission code and the project's restricted view settings for a given shortcode and filename.", ) - - val endpoints: Seq[AnyEndpoint] = Seq( - getAdminFilesShortcodeFileIri, - ).map(_.endpoint.tag("Admin Files")) } object FilesEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesServerEndpoints.scala similarity index 52% rename from webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpointsHandler.scala rename to webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesServerEndpoints.scala index 3daed742389..ca056ff910f 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesEndpointsHandler.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/FilesServerEndpoints.scala @@ -5,31 +5,26 @@ package org.knora.webapi.slice.admin.api -import zio.ZLayer +import sttp.tapir.ztapir.* +import zio.* import org.knora.webapi.responders.admin.AssetPermissionsResponder import org.knora.webapi.slice.admin.api.model.PermissionCodeAndProjectRestrictedViewSettings import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler import org.knora.webapi.slice.common.domain.SparqlEncodedString -final case class FilesEndpointsHandler( - filesEndpoints: FilesEndpoints, - assetPermissionsResponder: AssetPermissionsResponder, - mapper: HandlerMapper, +final case class FilesServerEndpoints( + private val filesEndpoints: FilesEndpoints, + private val assetPermissionsResponder: AssetPermissionsResponder, ) { - - private val getAdminFilesShortcodeFileIri = - SecuredEndpointHandler( - filesEndpoints.getAdminFilesShortcodeFileIri, + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + filesEndpoints.getAdminFilesShortcodeFileIri.serverLogic( assetPermissionsResponder.getPermissionCodeAndProjectRestrictedViewSettings, - ) - - val allHandlers = List(getAdminFilesShortcodeFileIri).map(mapper.mapSecuredEndpointHandler) + ), + ).map(_.withTag("Admin Files")) } -object FilesEndpointsHandler { - val layer = ZLayer.derive[FilesEndpointsHandler] +object FilesServerEndpoints { + val layer = ZLayer.derive[FilesServerEndpoints] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpoints.scala index 3d21fc9aec3..ea6d3bc0b01 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpoints.scala @@ -69,11 +69,6 @@ final case class GroupsEndpoints(baseEndpoints: BaseEndpoints) { .in(base / groupIriPathVar) .out(groupGetResponse) .description("Deletes a group by changing its status to 'false'.") - - private val securedEndpoints = Seq(getGroupMembers, postGroup, putGroup, putGroupStatus, deleteGroup).map(_.endpoint) - - val endpoints: Seq[AnyEndpoint] = (Seq(getGroups, getGroupByIri) ++ securedEndpoints) - .map(_.tag("Admin Groups")) } object GroupsRequests { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpointsHandler.scala deleted file mode 100644 index 6dc4aa649db..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsEndpointsHandler.scala +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.admin.api - -import zio.ZLayer - -import org.knora.webapi.messages.admin.responder.groupsmessages.GroupGetResponseADM -import org.knora.webapi.slice.admin.api.GroupsRequests.GroupStatusUpdateRequest -import org.knora.webapi.slice.admin.api.GroupsRequests.GroupUpdateRequest -import org.knora.webapi.slice.admin.api.service.GroupRestService -import org.knora.webapi.slice.admin.domain.model.GroupIri -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -case class GroupsEndpointsHandler( - endpoints: GroupsEndpoints, - restService: GroupRestService, - mapper: HandlerMapper, -) { - - val allHandlers = - List( - PublicEndpointHandler(endpoints.getGroups, (_: Unit) => restService.getGroups), - PublicEndpointHandler(endpoints.getGroupByIri, restService.getGroupByIri), - ).map(mapper.mapPublicEndpointHandler) ++ - List( - SecuredEndpointHandler(endpoints.getGroupMembers, restService.getGroupMembers), - SecuredEndpointHandler(endpoints.postGroup, restService.postGroup), - SecuredEndpointHandler(endpoints.putGroup, restService.putGroup), - SecuredEndpointHandler(endpoints.putGroupStatus, restService.putGroupStatus), - SecuredEndpointHandler(endpoints.deleteGroup, restService.deleteGroup), - ).map(mapper.mapSecuredEndpointHandler) -} - -object GroupsEndpointsHandler { - val layer = ZLayer.derive[GroupsEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsServerEndpoints.scala new file mode 100644 index 00000000000..466bb691e00 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/GroupsServerEndpoints.scala @@ -0,0 +1,32 @@ +/* + * 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.admin.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.api.GroupsRequests.GroupStatusUpdateRequest +import org.knora.webapi.slice.admin.api.GroupsRequests.GroupUpdateRequest +import org.knora.webapi.slice.admin.api.service.GroupRestService +import org.knora.webapi.slice.admin.domain.model.GroupIri + +case class GroupsServerEndpoints( + private val endpoints: GroupsEndpoints, + private val restService: GroupRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getGroups.zServerLogic(_ => restService.getGroups), + endpoints.getGroupByIri.zServerLogic(restService.getGroupByIri), + endpoints.getGroupMembers.serverLogic(restService.getGroupMembers), + endpoints.postGroup.serverLogic(restService.postGroup), + endpoints.putGroup.serverLogic(restService.putGroup), + endpoints.putGroupStatus.serverLogic(restService.putGroupStatus), + endpoints.deleteGroup.serverLogic(restService.deleteGroup), + ).map(_.withTag("Admin Groups")) +} +object GroupsServerEndpoints { + val layer = ZLayer.derive[GroupsServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpoints.scala index d73c73355ad..2e1a56b3068 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpoints.scala @@ -39,8 +39,6 @@ final case class MaintenanceEndpoints(baseEndpoints: BaseEndpoints) { |""".stripMargin), ) .out(statusCode(StatusCode.Accepted)) - - val endpoints: Seq[AnyEndpoint] = Seq(postMaintenance).map(_.endpoint.tag("Admin Maintenance")) } object MaintenanceEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpointsHandlers.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpointsHandlers.scala deleted file mode 100644 index bf0a7a59588..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceEndpointsHandlers.scala +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.admin.api - -import zio.ZIO -import zio.ZLayer -import zio.json.ast.Json - -import org.knora.webapi.slice.admin.api.service.MaintenanceRestService -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -final case class MaintenanceEndpointsHandlers( - endpoints: MaintenanceEndpoints, - restService: MaintenanceRestService, - mapper: HandlerMapper, -) { - - val allHandlers = List( - SecuredEndpointHandler(endpoints.postMaintenance, restService.executeMaintenanceAction), - ).map(mapper.mapSecuredEndpointHandler) -} - -object MaintenanceEndpointsHandlers { - val layer = ZLayer.derive[MaintenanceEndpointsHandlers] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceServerEndpoints.scala new file mode 100644 index 00000000000..cf7d76cbc2a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/MaintenanceServerEndpoints.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.admin.api + +import sttp.tapir.ztapir.* +import zio.* +import zio.json.ast.Json + +import org.knora.webapi.slice.admin.api.service.MaintenanceRestService +import org.knora.webapi.slice.admin.domain.model.User + +final case class MaintenanceServerEndpoints( + private val endpoints: MaintenanceEndpoints, + private val restService: MaintenanceRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.postMaintenance.serverLogic(restService.executeMaintenanceAction), + ).map(_.withTag("Admin Maintenance")) +} +object MaintenanceServerEndpoints { + val layer = ZLayer.derive[MaintenanceServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala index 51bee1f3773..55e6fdc57e9 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpoints.scala @@ -149,21 +149,6 @@ final case class PermissionsEndpoints(base: BaseEndpoints) { .in(jsonBody[ChangePermissionPropertyApiRequestADM]) .out(jsonBody[DefaultObjectAccessPermissionGetResponseADM]) .deprecated() - - val endpoints: Seq[AnyEndpoint] = Seq( - postPermissionsAp, - getPermissionsApByProjectIri, - getPermissionsApByProjectAndGroupIri, - getPermissionsDoapByProjectIri, - getPermissionsByProjectIri, - deletePermission, - postPermissionsDoap, - putPermissionsDoapForWhat, - putPermissionsProjectIriGroup, - putPerrmissionsHasPermissions, - putPermisssionsResourceClass, - putPermissionsProperty, - ).map(_.endpoint.tag("Admin Permissions")) } object PermissionsEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala deleted file mode 100644 index 79675cc2f8a..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsEndpointsHandlers.scala +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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.admin.api - -import zio.ZLayer - -import org.knora.webapi.messages.admin.responder.permissionsmessages.AdministrativePermissionCreateResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.AdministrativePermissionGetResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.AdministrativePermissionsForProjectGetResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionGroupApiRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionHasPermissionsApiRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionPropertyApiRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionResourceClassApiRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateAdministrativePermissionAPIRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateDefaultObjectAccessPermissionAPIRequestADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.DefaultObjectAccessPermissionCreateResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.DefaultObjectAccessPermissionGetResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.DefaultObjectAccessPermissionsForProjectGetResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionDeleteResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionGetResponseADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsForProjectGetResponseADM -import org.knora.webapi.slice.admin.api.PermissionEndpointsRequests.ChangeDoapRequest -import org.knora.webapi.slice.admin.api.service.PermissionRestService -import org.knora.webapi.slice.admin.domain.model.GroupIri -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.admin.domain.model.PermissionIri -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -final case class PermissionsEndpointsHandlers( - permissionsEndpoints: PermissionsEndpoints, - restService: PermissionRestService, - mapper: HandlerMapper, -) { - - val allHanders = List( - SecuredEndpointHandler(permissionsEndpoints.postPermissionsAp, restService.createAdministrativePermission), - SecuredEndpointHandler(permissionsEndpoints.getPermissionsApByProjectIri, restService.getPermissionsApByProjectIri), - SecuredEndpointHandler( - permissionsEndpoints.getPermissionsApByProjectAndGroupIri, - restService.getPermissionsApByProjectAndGroupIri, - ), - SecuredEndpointHandler( - permissionsEndpoints.getPermissionsDoapByProjectIri, - restService.getPermissionsDaopByProjectIri, - ), - SecuredEndpointHandler(permissionsEndpoints.getPermissionsByProjectIri, restService.getPermissionsByProjectIri), - SecuredEndpointHandler(permissionsEndpoints.putPermissionsDoapForWhat, restService.updateDoapForWhat), - SecuredEndpointHandler(permissionsEndpoints.putPermissionsProjectIriGroup, restService.updatePermissionGroup), - SecuredEndpointHandler( - permissionsEndpoints.putPerrmissionsHasPermissions, - restService.updatePermissionHasPermissions, - ), - SecuredEndpointHandler(permissionsEndpoints.putPermissionsProperty, restService.updatePermissionProperty), - SecuredEndpointHandler( - permissionsEndpoints.putPermisssionsResourceClass, - restService.updatePermissionResourceClass, - ), - SecuredEndpointHandler(permissionsEndpoints.deletePermission, restService.deletePermission), - SecuredEndpointHandler(permissionsEndpoints.postPermissionsDoap, restService.createDefaultObjectAccessPermission), - ).map(mapper.mapSecuredEndpointHandler) -} - -object PermissionsEndpointsHandlers { - - val layer = ZLayer.derive[PermissionsEndpointsHandlers] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsServerEndpoints.scala new file mode 100644 index 00000000000..0c54c559045 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/PermissionsServerEndpoints.scala @@ -0,0 +1,47 @@ +/* + * 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.admin.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionGroupApiRequestADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionHasPermissionsApiRequestADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionPropertyApiRequestADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.ChangePermissionResourceClassApiRequestADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateAdministrativePermissionAPIRequestADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.CreateDefaultObjectAccessPermissionAPIRequestADM +import org.knora.webapi.slice.admin.api.PermissionEndpointsRequests.ChangeDoapRequest +import org.knora.webapi.slice.admin.api.service.PermissionRestService +import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.PermissionIri + +final case class PermissionsServerEndpoints( + private val permissionsEndpoints: PermissionsEndpoints, + private val restService: PermissionRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + permissionsEndpoints.postPermissionsAp.serverLogic(restService.createAdministrativePermission), + permissionsEndpoints.getPermissionsApByProjectIri.serverLogic(restService.getPermissionsApByProjectIri), + permissionsEndpoints.getPermissionsApByProjectAndGroupIri.serverLogic( + restService.getPermissionsApByProjectAndGroupIri, + ), + permissionsEndpoints.getPermissionsDoapByProjectIri.serverLogic(restService.getPermissionsDaopByProjectIri), + permissionsEndpoints.getPermissionsByProjectIri.serverLogic(restService.getPermissionsByProjectIri), + permissionsEndpoints.putPermissionsDoapForWhat.serverLogic(restService.updateDoapForWhat), + permissionsEndpoints.putPermissionsProjectIriGroup.serverLogic(restService.updatePermissionGroup), + permissionsEndpoints.putPerrmissionsHasPermissions.serverLogic(restService.updatePermissionHasPermissions), + permissionsEndpoints.putPermissionsProperty.serverLogic(restService.updatePermissionProperty), + permissionsEndpoints.putPermisssionsResourceClass.serverLogic(restService.updatePermissionResourceClass), + permissionsEndpoints.deletePermission.serverLogic(restService.deletePermission), + permissionsEndpoints.postPermissionsDoap.serverLogic(restService.createDefaultObjectAccessPermission), + ).map(_.endpoint.tag("Admin Permissions")) +} +object PermissionsServerEndpoints { + val layer = ZLayer.derive[PermissionsServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala index 1b26c9a5396..4fc3a37d800 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpoints.scala @@ -5,7 +5,7 @@ package org.knora.webapi.slice.admin.api -import sttp.capabilities.pekko.PekkoStreams +import sttp.capabilities.zio.ZioStreams import sttp.model.StatusCode import sttp.tapir.* import sttp.tapir.generic.auto.* @@ -210,40 +210,9 @@ final case class ProjectsEndpoints( .in(projectsByIri / "AllData") .out(header[String]("Content-Disposition")) .out(header[String]("Content-Type")) - .out(streamBinaryBody(PekkoStreams)(CodecFormat.OctetStream())) + .out(streamBinaryBody(ZioStreams)(CodecFormat.OctetStream())) .description("Returns all ontologies, data, and configuration belonging to a project identified by the IRI.") } - - val endpoints: Seq[AnyEndpoint] = - (Seq( - Public.getAdminProjects, - Public.getAdminProjectsByProjectIri, - Public.getAdminProjectsByProjectIriRestrictedViewSettings, - Public.getAdminProjectsByProjectShortcode, - Public.getAdminProjectsByProjectShortcodeRestrictedViewSettings, - Public.getAdminProjectsByProjectShortname, - Public.getAdminProjectsByProjectShortnameRestrictedViewSettings, - Public.getAdminProjectsKeywords, - Public.getAdminProjectsKeywordsByProjectIri, - ) ++ Seq( - Secured.deleteAdminProjectsByIri, - Secured.deleteAdminProjectsByProjectShortcodeErase, - Secured.getAdminProjectsByIriAllData, - Secured.getAdminProjectsByProjectIriAdminMembers, - Secured.getAdminProjectsByProjectIriMembers, - Secured.getAdminProjectsByProjectShortcodeAdminMembers, - Secured.getAdminProjectsByProjectShortcodeMembers, - Secured.getAdminProjectsByProjectShortnameAdminMembers, - Secured.getAdminProjectsByProjectShortnameMembers, - Secured.getAdminProjectsExports, - Secured.postAdminProjects, - Secured.postAdminProjectsByShortcodeExport, - Secured.postAdminProjectsByShortcodeExportAwaiting, - Secured.postAdminProjectsByShortcodeImport, - Secured.putAdminProjectsByIri, - Secured.postAdminProjectsByProjectIriRestrictedViewSettings, - Secured.postAdminProjectsByProjectShortcodeRestrictedViewSettings, - ).map(_.endpoint)).map(_.tag("Admin Projects")) } object ProjectsEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala deleted file mode 100644 index 9940a43b825..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsEndpointsHandler.scala +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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.admin.api - -import org.apache.pekko.stream.scaladsl.FileIO -import zio.ZLayer - -import java.nio.file.Files -import scala.concurrent.ExecutionContext - -import org.knora.webapi.slice.admin.api.model.* -import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.ProjectCreateRequest -import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.ProjectUpdateRequest -import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.RestrictedViewResponse -import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.SetRestrictedViewRequest -import org.knora.webapi.slice.admin.api.service.ProjectRestService -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode -import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -final case class ProjectsEndpointsHandler( - projectsEndpoints: ProjectsEndpoints, - restService: ProjectRestService, - mapper: HandlerMapper, -) { - - val getAdminProjectsByIriAllDataHandler = { - implicit val ec: ExecutionContext = ExecutionContext.global - projectsEndpoints.Secured.getAdminProjectsByIriAllData.serverLogic((user: User) => - (iri: ProjectIri) => - // Future[Either[RequestRejectedException, (String, String, PekkoStreams.BinaryStream]] - mapper.runToFuture( - restService - .getAllProjectData(user)(iri) - .map { result => - val path = result.projectDataFile -// On Pekko use pekko-streams to stream the file, but when running on zio-http we use ZStream: -// val stream = ZStream -// .fromPath(path) -// .ensuringWith(_ => ZIO.attempt(Files.deleteIfExists(path)).ignore) - val stream = FileIO - .fromPath(path) - .watchTermination() { case (_, result) => result.onComplete(_ => Files.deleteIfExists(path)) } - (s"attachment; filename=project-data.trig", "application/octet-stream", stream) - }, - ), - ) - } - - private val handlers = - List( - PublicEndpointHandler(projectsEndpoints.Public.getAdminProjects, restService.listAllProjects), - PublicEndpointHandler(projectsEndpoints.Public.getAdminProjectsKeywords, restService.listAllKeywords), - PublicEndpointHandler(projectsEndpoints.Public.getAdminProjectsByProjectIri, restService.findById), - PublicEndpointHandler(projectsEndpoints.Public.getAdminProjectsByProjectShortcode, restService.findByShortcode), - PublicEndpointHandler(projectsEndpoints.Public.getAdminProjectsByProjectShortname, restService.findByShortname), - PublicEndpointHandler( - projectsEndpoints.Public.getAdminProjectsKeywordsByProjectIri, - restService.getKeywordsByProjectIri, - ), - PublicEndpointHandler( - projectsEndpoints.Public.getAdminProjectsByProjectIriRestrictedViewSettings, - restService.getProjectRestrictedViewSettingsById, - ), - PublicEndpointHandler( - projectsEndpoints.Public.getAdminProjectsByProjectShortcodeRestrictedViewSettings, - restService.getProjectRestrictedViewSettingsByShortcode, - ), - PublicEndpointHandler( - projectsEndpoints.Public.getAdminProjectsByProjectShortnameRestrictedViewSettings, - restService.getProjectRestrictedViewSettingsByShortname, - ), - ).map(mapper.mapPublicEndpointHandler(_)) - - private val secureHandlers = getAdminProjectsByIriAllDataHandler :: List( - SecuredEndpointHandler( - projectsEndpoints.Secured.postAdminProjectsByProjectIriRestrictedViewSettings, - restService.updateProjectRestrictedViewSettingsById, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.postAdminProjectsByProjectShortcodeRestrictedViewSettings, - restService.updateProjectRestrictedViewSettingsByShortcode, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectIriMembers, - restService.getProjectMembersById, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectShortcodeMembers, - restService.getProjectMembersByShortcode, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectShortnameMembers, - restService.getProjectMembersByShortname, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectIriAdminMembers, - restService.getProjectAdminMembersById, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectShortcodeAdminMembers, - restService.getProjectAdminMembersByShortcode, - ), - SecuredEndpointHandler( - projectsEndpoints.Secured.getAdminProjectsByProjectShortnameAdminMembers, - restService.getProjectAdminMembersByShortname, - ), - SecuredEndpointHandler(projectsEndpoints.Secured.deleteAdminProjectsByIri, restService.deleteProject), - SecuredEndpointHandler( - projectsEndpoints.Secured.deleteAdminProjectsByProjectShortcodeErase, - restService.eraseProject, - ), - SecuredEndpointHandler(projectsEndpoints.Secured.getAdminProjectsExports, restService.listExports), - SecuredEndpointHandler(projectsEndpoints.Secured.postAdminProjectsByShortcodeExport, restService.exportProject), - SecuredEndpointHandler( - projectsEndpoints.Secured.postAdminProjectsByShortcodeExportAwaiting, - restService.exportProjectAwaiting, - ), - SecuredEndpointHandler(projectsEndpoints.Secured.postAdminProjectsByShortcodeImport, restService.importProject), - SecuredEndpointHandler(projectsEndpoints.Secured.postAdminProjects, restService.createProject), - SecuredEndpointHandler(projectsEndpoints.Secured.putAdminProjectsByIri, restService.updateProject), - ).map(mapper.mapSecuredEndpointHandler) - - val allHanders = handlers ++ secureHandlers -} - -object ProjectsEndpointsHandler { - val layer = ZLayer.derive[ProjectsEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEndpoints.scala index e1b61306d86..36b291dec10 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEndpoints.scala @@ -141,16 +141,6 @@ final case class ProjectsLegalInfoEndpoints(baseEndpoints: BaseEndpoints) { "Update a particular allowed copyright holder for use within this project, does not update existing values on assets. " + "The user must be a system admin.", ) - - val endpoints: Seq[AnyEndpoint] = (Seq(getProjectLicenses, getProjectLicensesIri) ++ - Seq( - getProjectAuthorships, - putProjectLicensesEnable, - putProjectLicensesDisable, - getProjectCopyrightHolders, - postProjectCopyrightHolders, - putProjectCopyrightHolders, - ).map(_.endpoint)).map(_.tag("Admin Projects (Legal Info)")) } object ProjectsLegalInfoEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEntpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEntpointsHandler.scala deleted file mode 100644 index b3cbae4e8a3..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoEntpointsHandler.scala +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.admin.api -import zio.ZLayer - -import org.knora.webapi.slice.admin.api.service.ProjectsLegalInfoRestService -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -final class ProjectsLegalInfoEndpointsHandler( - endpoints: ProjectsLegalInfoEndpoints, - restService: ProjectsLegalInfoRestService, - mapper: HandlerMapper, -) { - val allHandlers = - List( - PublicEndpointHandler(endpoints.getProjectLicenses, restService.findLicenses), - PublicEndpointHandler(endpoints.getProjectLicensesIri, restService.findAvailableLicenseByIdAndShortcode), - ) - .map(mapper.mapPublicEndpointHandler) ++ - List( - SecuredEndpointHandler(endpoints.getProjectAuthorships, restService.findAuthorships), - SecuredEndpointHandler(endpoints.putProjectLicensesEnable, restService.enableLicense), - SecuredEndpointHandler(endpoints.putProjectLicensesDisable, restService.disableLicense), - SecuredEndpointHandler(endpoints.getProjectCopyrightHolders, restService.findCopyrightHolders), - SecuredEndpointHandler(endpoints.postProjectCopyrightHolders, restService.addCopyrightHolders), - SecuredEndpointHandler(endpoints.putProjectCopyrightHolders, restService.replaceCopyrightHolder), - ).map(mapper.mapSecuredEndpointHandler) -} - -object ProjectsLegalInfoEndpointsHandler { - val layer = ZLayer.derive[ProjectsLegalInfoEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoServerEndpoints.scala new file mode 100644 index 00000000000..af195b60ff7 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsLegalInfoServerEndpoints.scala @@ -0,0 +1,30 @@ +/* + * 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.admin.api +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.api.service.ProjectsLegalInfoRestService + +final class ProjectsLegalInfoServerEndpoints( + private val endpoints: ProjectsLegalInfoEndpoints, + private val restService: ProjectsLegalInfoRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getProjectLicenses.zServerLogic(restService.findLicenses), + endpoints.getProjectLicensesIri.zServerLogic(restService.findAvailableLicenseByIdAndShortcode), + endpoints.getProjectAuthorships.serverLogic(restService.findAuthorships), + endpoints.putProjectLicensesEnable.serverLogic(restService.enableLicense), + endpoints.putProjectLicensesDisable.serverLogic(restService.disableLicense), + endpoints.getProjectCopyrightHolders.serverLogic(restService.findCopyrightHolders), + endpoints.postProjectCopyrightHolders.serverLogic(restService.addCopyrightHolders), + endpoints.putProjectCopyrightHolders.serverLogic(restService.replaceCopyrightHolder), + ).map(_.withTag("Admin Projects (Legal Info)")) +} + +object ProjectsLegalInfoServerEndpoints { + val layer = ZLayer.derive[ProjectsLegalInfoServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsServerEndpoints.scala new file mode 100644 index 00000000000..baeda381079 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/ProjectsServerEndpoints.scala @@ -0,0 +1,68 @@ +/* + * 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.admin.api + +import sttp.capabilities.zio.ZioStreams +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.ProjectCreateRequest +import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.ProjectUpdateRequest +import org.knora.webapi.slice.admin.api.model.ProjectsEndpointsRequestsAndResponses.SetRestrictedViewRequest +import org.knora.webapi.slice.admin.api.service.ProjectRestService +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.admin.domain.model.User + +final case class ProjectsServerEndpoints( + private val projectsEndpoints: ProjectsEndpoints, + private val restService: ProjectRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, ZioStreams]] = List( + projectsEndpoints.Public.getAdminProjects.zServerLogic(restService.listAllProjects), + projectsEndpoints.Public.getAdminProjectsKeywords.zServerLogic(restService.listAllKeywords), + projectsEndpoints.Public.getAdminProjectsByProjectIri.zServerLogic(restService.findById), + projectsEndpoints.Public.getAdminProjectsByProjectShortcode.zServerLogic(restService.findByShortcode), + projectsEndpoints.Public.getAdminProjectsByProjectShortname.zServerLogic(restService.findByShortname), + projectsEndpoints.Public.getAdminProjectsKeywordsByProjectIri.zServerLogic(restService.getKeywordsByProjectIri), + projectsEndpoints.Public.getAdminProjectsByProjectIriRestrictedViewSettings + .zServerLogic(restService.getProjectRestrictedViewSettingsById), + projectsEndpoints.Public.getAdminProjectsByProjectShortcodeRestrictedViewSettings + .zServerLogic(restService.getProjectRestrictedViewSettingsByShortcode), + projectsEndpoints.Public.getAdminProjectsByProjectShortnameRestrictedViewSettings + .zServerLogic(restService.getProjectRestrictedViewSettingsByShortname), + projectsEndpoints.Secured.getAdminProjectsByIriAllData.serverLogic(restService.getAllProjectData), + projectsEndpoints.Secured.postAdminProjectsByProjectIriRestrictedViewSettings + .serverLogic(restService.updateProjectRestrictedViewSettingsById), + projectsEndpoints.Secured.postAdminProjectsByProjectShortcodeRestrictedViewSettings + .serverLogic(restService.updateProjectRestrictedViewSettingsByShortcode), + projectsEndpoints.Secured.getAdminProjectsByProjectIriMembers.serverLogic(restService.getProjectMembersById), + projectsEndpoints.Secured.getAdminProjectsByProjectShortcodeMembers + .serverLogic(restService.getProjectMembersByShortcode), + projectsEndpoints.Secured.getAdminProjectsByProjectShortnameMembers + .serverLogic(restService.getProjectMembersByShortname), + projectsEndpoints.Secured.getAdminProjectsByProjectIriAdminMembers + .serverLogic(restService.getProjectAdminMembersById), + projectsEndpoints.Secured.getAdminProjectsByProjectShortcodeAdminMembers + .serverLogic(restService.getProjectAdminMembersByShortcode), + projectsEndpoints.Secured.getAdminProjectsByProjectShortnameAdminMembers + .serverLogic(restService.getProjectAdminMembersByShortname), + projectsEndpoints.Secured.deleteAdminProjectsByIri.serverLogic(restService.deleteProject), + projectsEndpoints.Secured.deleteAdminProjectsByProjectShortcodeErase.serverLogic(restService.eraseProject), + projectsEndpoints.Secured.getAdminProjectsExports.serverLogic(restService.listExports), + projectsEndpoints.Secured.postAdminProjectsByShortcodeExport.serverLogic(restService.exportProject), + projectsEndpoints.Secured.postAdminProjectsByShortcodeExportAwaiting.serverLogic(restService.exportProjectAwaiting), + projectsEndpoints.Secured.postAdminProjectsByShortcodeImport.serverLogic(restService.importProject), + projectsEndpoints.Secured.postAdminProjects.serverLogic(restService.createProject), + projectsEndpoints.Secured.putAdminProjectsByIri.serverLogic(restService.updateProject), + ).map(_.withTag("Admin Projects")) +} + +object ProjectsServerEndpoints { + val layer = ZLayer.derive[ProjectsServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpoints.scala index 6c8fc860ff8..db3fd5c6cf1 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpoints.scala @@ -35,8 +35,6 @@ final case class StoreEndpoints(baseEndpoints: BaseEndpoints) { .description( "Resets the content of the triplestore, only available if configuration `allowReloadOverHttp` is set to `true`.", ) - - val endpoints = Seq(postStoreResetTriplestoreContent).map(_.tag("Admin Store")) } object StoreEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpointsHandler.scala deleted file mode 100644 index bf115e9978b..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreEndpointsHandler.scala +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.admin.api - -import zio.ZLayer - -import org.knora.webapi.config.AppConfig -import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject -import org.knora.webapi.slice.admin.api.service.StoreRestService -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler - -final case class StoreEndpointsHandler( - private val appConfig: AppConfig, - private val endpoints: StoreEndpoints, - private val mapper: HandlerMapper, - private val restService: StoreRestService, -) { - - val allHandlers = { - val handlerIfConfigured = - if (appConfig.allowReloadOverHttp) - Seq(PublicEndpointHandler(endpoints.postStoreResetTriplestoreContent, restService.resetTriplestoreContent)) - else Seq.empty - handlerIfConfigured.map(mapper.mapPublicEndpointHandler) - } -} - -object StoreEndpointsHandler { - val layer = ZLayer.derive[StoreEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreServerEndpoints.scala new file mode 100644 index 00000000000..c34f0cb812d --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/StoreServerEndpoints.scala @@ -0,0 +1,29 @@ +/* + * 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.admin.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.config.AppConfig +import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject +import org.knora.webapi.slice.admin.api.service.StoreRestService + +final case class StoreServerEndpoints( + private val appConfig: AppConfig, + private val endpoints: StoreEndpoints, + private val restService: StoreRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = + (if (appConfig.allowReloadOverHttp) + List(endpoints.postStoreResetTriplestoreContent.zServerLogic(restService.resetTriplestoreContent)) + else List.empty).map(_.withTag("Admin Store")) +} + +object StoreServerEndpoints { + val layer = ZLayer.derive[StoreServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala index bd93b5a7d4a..14d69ea354e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala @@ -163,33 +163,6 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { .out(jsonBody[UserResponse]) .description("Remove a user form an group membership identified by IRI.") } - - private val public = - Seq( - get.usersByIriProjectMemberShips, - get.usersByIriProjectAdminMemberShips, - get.usersByIriGroupMemberships, - ) - private val secured = - Seq( - get.users, - get.userByIri, - get.userByEmail, - get.userByUsername, - post.users, - post.usersByIriProjectMemberShips, - post.usersByIriProjectAdminMemberShips, - post.usersByIriGroupMemberShips, - put.usersIriBasicInformation, - put.usersIriPassword, - put.usersIriStatus, - put.usersIriSystemAdmin, - delete.deleteUser, - delete.usersByIriProjectMemberShips, - delete.usersByIriProjectAdminMemberShips, - delete.usersByIriGroupMemberShips, - ).map(_.endpoint) - val endpoints: Seq[AnyEndpoint] = (public ++ secured).map(_.tag("Admin Users")) } object UsersEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala deleted file mode 100644 index 005eca7c32d..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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.admin.api - -import zio.ZLayer - -import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest -import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest -import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.StatusChangeRequest -import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.SystemAdminChangeRequest -import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest -import org.knora.webapi.slice.admin.api.service.UserRestService -import org.knora.webapi.slice.admin.api.service.UserRestService.UserResponse -import org.knora.webapi.slice.admin.domain.model.GroupIri -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.admin.domain.model.UserIri -import org.knora.webapi.slice.admin.domain.model.Username -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler - -case class UsersEndpointsHandler( - usersEndpoints: UsersEndpoints, - restService: UserRestService, - mapper: HandlerMapper, -) { - - private val public = List( - PublicEndpointHandler(usersEndpoints.get.usersByIriProjectMemberShips, restService.getProjectMemberShipsByUserIri), - PublicEndpointHandler( - usersEndpoints.get.usersByIriProjectAdminMemberShips, - restService.getProjectAdminMemberShipsByUserIri, - ), - PublicEndpointHandler(usersEndpoints.get.usersByIriGroupMemberships, restService.getGroupMemberShipsByIri), - ).map(mapper.mapPublicEndpointHandler(_)) - - private val secured = List( - SecuredEndpointHandler(usersEndpoints.get.users, user => _ => restService.getAllUsers(user)), - SecuredEndpointHandler(usersEndpoints.get.userByIri, restService.getUserByIri), - SecuredEndpointHandler(usersEndpoints.get.userByEmail, restService.getUserByEmail), - SecuredEndpointHandler(usersEndpoints.get.userByUsername, restService.getUserByUsername), - SecuredEndpointHandler(usersEndpoints.post.users, restService.createUser), - SecuredEndpointHandler(usersEndpoints.post.usersByIriProjectMemberShips, restService.addUserToProject), - SecuredEndpointHandler(usersEndpoints.post.usersByIriProjectAdminMemberShips, restService.addUserToProjectAsAdmin), - SecuredEndpointHandler(usersEndpoints.post.usersByIriGroupMemberShips, restService.addUserToGroup), - SecuredEndpointHandler(usersEndpoints.put.usersIriBasicInformation, restService.updateUser), - SecuredEndpointHandler(usersEndpoints.put.usersIriPassword, restService.changePassword), - SecuredEndpointHandler(usersEndpoints.put.usersIriStatus, restService.changeStatus), - SecuredEndpointHandler(usersEndpoints.put.usersIriSystemAdmin, restService.changeSystemAdmin), - SecuredEndpointHandler(usersEndpoints.delete.deleteUser, restService.deleteUser), - SecuredEndpointHandler(usersEndpoints.delete.usersByIriProjectMemberShips, restService.removeUserFromProject), - SecuredEndpointHandler( - usersEndpoints.delete.usersByIriProjectAdminMemberShips, - restService.removeUserFromProjectAsAdmin, - ), - SecuredEndpointHandler(usersEndpoints.delete.usersByIriGroupMemberShips, restService.removeUserFromGroup), - ).map(mapper.mapSecuredEndpointHandler(_)) - - val allHanders = public ++ secured -} - -object UsersEndpointsHandler { - val layer = ZLayer.derive[UsersEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersServerEndpoints.scala new file mode 100644 index 00000000000..098c42693dc --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersServerEndpoints.scala @@ -0,0 +1,53 @@ +/* + * 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.admin.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.StatusChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.SystemAdminChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest +import org.knora.webapi.slice.admin.api.service.UserRestService +import org.knora.webapi.slice.admin.domain.model.Email +import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.UserIri +import org.knora.webapi.slice.admin.domain.model.Username + +case class UsersServerEndpoints( + private val usersEndpoints: UsersEndpoints, + private val restService: UserRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + usersEndpoints.get.usersByIriProjectMemberShips.zServerLogic(restService.getProjectMemberShipsByUserIri), + usersEndpoints.get.usersByIriProjectAdminMemberShips.zServerLogic(restService.getProjectAdminMemberShipsByUserIri), + usersEndpoints.get.usersByIriGroupMemberships.zServerLogic(restService.getGroupMemberShipsByIri), + usersEndpoints.get.users.serverLogic(user => _ => restService.getAllUsers(user)), + usersEndpoints.get.userByIri.serverLogic(restService.getUserByIri), + usersEndpoints.get.userByEmail.serverLogic(restService.getUserByEmail), + usersEndpoints.get.userByUsername.serverLogic(restService.getUserByUsername), + usersEndpoints.post.users.serverLogic(restService.createUser), + usersEndpoints.post.usersByIriProjectMemberShips.serverLogic(restService.addUserToProject), + usersEndpoints.post.usersByIriProjectAdminMemberShips.serverLogic(restService.addUserToProjectAsAdmin), + usersEndpoints.post.usersByIriGroupMemberShips.serverLogic(restService.addUserToGroup), + usersEndpoints.put.usersIriBasicInformation.serverLogic(restService.updateUser), + usersEndpoints.put.usersIriPassword.serverLogic(restService.changePassword), + usersEndpoints.put.usersIriStatus.serverLogic(restService.changeStatus), + usersEndpoints.put.usersIriSystemAdmin.serverLogic(restService.changeSystemAdmin), + usersEndpoints.delete.deleteUser.serverLogic(restService.deleteUser), + usersEndpoints.delete.usersByIriProjectMemberShips.serverLogic(restService.removeUserFromProject), + usersEndpoints.delete.usersByIriProjectAdminMemberShips.serverLogic(restService.removeUserFromProjectAsAdmin), + usersEndpoints.delete.usersByIriGroupMemberShips.serverLogic(restService.removeUserFromGroup), + ).map(_.withTag("Admin Users")) +} + +object UsersServerEndpoints { + val layer = ZLayer.derive[UsersServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/model/UserDto.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/model/UserDto.scala index 9008336cb8d..a409a9b04cd 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/model/UserDto.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/model/UserDto.scala @@ -10,6 +10,7 @@ import zio.json.JsonCodec import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsDataADM import org.knora.webapi.slice.admin.domain.model.Group import org.knora.webapi.slice.admin.domain.model.User +import org.knora.webapi.slice.admin.domain.model.UserIri final case class UserDto( id: String, @@ -22,7 +23,9 @@ final case class UserDto( groups: Seq[Group], projects: Seq[Project], permissions: PermissionsDataADM, -) +) { + def userIri: UserIri = UserIri.unsafeFrom(id) +} object UserDto { given JsonCodec[UserDto] = DeriveJsonCodec.gen[UserDto] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/GroupRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/GroupRestService.scala index 04b98ed86cb..91eb8402d5d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/GroupRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/GroupRestService.scala @@ -34,7 +34,7 @@ final case class GroupRestService( ) { def getGroups: Task[GroupsGetResponseADM] = for { - internal <- groupService.findAllRegularGroups + internal <- groupService.findAllRegularGroups.orDie external <- format.toExternal(GroupsGetResponseADM(internal)) } yield external diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala index 193995e2c21..88ca8f9284f 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectRestService.scala @@ -6,6 +6,8 @@ package org.knora.webapi.slice.admin.api.service import zio.* +import zio.nio.file.Files +import zio.stream.ZStream import dsp.errors.BadRequestException import dsp.errors.ForbiddenException @@ -177,16 +179,17 @@ final case class ProjectRestService( * @param id the [[IriIdentifier]] of the project * @param user the [[User]] making the request * @return - * '''success''': data of the project as [[ProjectDataGetResponseADM]] + * '''success''': data of the project for download as a stream of bytes, along with a suggested filename and a MIME type * * '''failure''': [[dsp.errors.NotFoundException]] when no project for the given [[IriIdentifier]] can be found * [[dsp.errors.ForbiddenException]] when the requesting user is not allowed to perform the operation */ - def getAllProjectData(user: User)(id: ProjectIri): Task[ProjectDataGetResponseADM] = + def getAllProjectData(user: User)(id: ProjectIri): Task[(String, String, ZStream[Any, Throwable, Byte])] = for { project <- auth.ensureSystemAdminOrProjectAdminById(user, id) - result <- projectExportService.exportProjectTriples(project).map(_.toFile.toPath) - } yield ProjectDataGetResponseADM(result) + path <- projectExportService.exportProjectTriples(project) + stream = ZStream.fromPath(path.toFile.toPath).ensuringWith(_ => ZIO.attempt(Files.deleteIfExists(path)).ignore) + } yield (s"attachment; filename=project-data.trig", "application/octet-stream", stream) def getProjectMembersById(user: User)(id: ProjectIri): Task[ProjectMembersGetResponseADM] = auth.ensureSystemAdminOrProjectAdminById(user, id).flatMap(findProjectMembers) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UserRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UserRestService.scala index 832f47a10f6..71facd736b0 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UserRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UserRestService.scala @@ -83,30 +83,27 @@ final case class UserRestService( .map(UserGroupMembershipsGetResponseADM.apply) .flatMap(format.toExternal) - def createUser(requestingUser: User)(userCreateRequest: Requests.UserCreateRequest): Task[UserResponse] = - for { - _ <- if (userCreateRequest.systemAdmin.value) { auth.ensureSystemAdmin(requestingUser) } - else { auth.ensureSystemAdminOrProjectAdminInAnyProject(requestingUser) } - internal <- knoraUserService.createNewUser(userCreateRequest) - external <- asExternalUserResponse(requestingUser, internal) - } yield external + def createUser(requestingUser: User)(userCreateRequest: Requests.UserCreateRequest): Task[UserResponse] = for { + _ <- if (userCreateRequest.systemAdmin.value) { auth.ensureSystemAdmin(requestingUser) } + else { auth.ensureSystemAdminOrProjectAdminInAnyProject(requestingUser) } + internal <- knoraUserService.createNewUser(userCreateRequest) + external <- asExternalUserResponse(requestingUser, internal) + } yield external - def getProjectMemberShipsByUserIri(userIri: UserIri): Task[UserProjectMembershipsGetResponseADM] = - for { - kUser <- getKnoraUserOrNotFound(userIri) - projects <- projectService.findByIds(kUser.isInProject) - external <- format.toExternal(UserProjectMembershipsGetResponseADM(projects)) - } yield external + def getProjectMemberShipsByUserIri(userIri: UserIri): Task[UserProjectMembershipsGetResponseADM] = for { + kUser <- getKnoraUserOrNotFound(userIri) + projects <- projectService.findByIds(kUser.isInProject) + external <- format.toExternal(UserProjectMembershipsGetResponseADM(projects)) + } yield external private def getKnoraUserOrNotFound(userIri: UserIri) = knoraUserService.findById(userIri).someOrFail(NotFoundException(s"User with iri ${userIri.value} not found.")) - def getProjectAdminMemberShipsByUserIri(userIri: UserIri): Task[UserProjectAdminMembershipsGetResponseADM] = - for { - kUser <- getKnoraUserOrNotFound(userIri) - projects <- projectService.findByIds(kUser.isInProjectAdminGroup) - external <- format.toExternal(UserProjectAdminMembershipsGetResponseADM(projects)) - } yield external + def getProjectAdminMemberShipsByUserIri(userIri: UserIri): Task[UserProjectAdminMembershipsGetResponseADM] = for { + kUser <- getKnoraUserOrNotFound(userIri) + projects <- projectService.findByIds(kUser.isInProjectAdminGroup) + external <- format.toExternal(UserProjectAdminMembershipsGetResponseADM(projects)) + } yield external def getUserByUsername(requestingUser: User)(username: Username): Task[UserResponse] = for { user <- userService @@ -191,17 +188,16 @@ final case class UserRestService( def addUserToProject(requestingUser: User)( userIri: UserIri, projectIri: ProjectIri, - ): Task[UserResponse] = - for { - _ <- ensureNotABuiltInUser(userIri) - _ <- auth.ensureSystemAdminOrProjectAdminById(requestingUser, projectIri) - kUser <- getKnoraUserOrNotFound(userIri) - project <- getProjectADMOrBadRequest(projectIri) - updatedUser <- knoraUserService.addUserToProject(kUser, project).mapError(BadRequestException.apply) - external <- asExternalUserResponse(requestingUser, updatedUser) - } yield external + ): Task[UserResponse] = for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdminById(requestingUser, projectIri) + kUser <- getKnoraUserOrNotFound(userIri) + project <- getProjectOrBadRequest(projectIri) + updatedUser <- knoraUserService.addUserToProject(kUser, project).mapError(BadRequestException.apply) + external <- asExternalUserResponse(requestingUser, updatedUser) + } yield external - private def getProjectADMOrBadRequest(projectIri: ProjectIri) = + private def getProjectOrBadRequest(projectIri: ProjectIri) = projectService .findById(projectIri) .someOrFail(BadRequestException(s"Project with iri ${projectIri.value} not found.")) @@ -233,7 +229,7 @@ final case class UserRestService( _ <- ensureNotABuiltInUser(userIri) _ <- auth.ensureSystemAdminOrProjectAdminById(requestingUser, projectIri) user <- getKnoraUserOrNotFound(userIri) - project <- getProjectADMOrBadRequest(projectIri) + project <- getProjectOrBadRequest(projectIri) updatedUser <- knoraUserService.addUserToProjectAsAdmin(user, project).mapError(BadRequestException.apply) external <- asExternalUserResponse(requestingUser, updatedUser) } yield external @@ -246,7 +242,7 @@ final case class UserRestService( _ <- ensureNotABuiltInUser(userIri) _ <- auth.ensureSystemAdminOrProjectAdminById(requestingUser, projectIri) user <- getKnoraUserOrNotFound(userIri) - project <- getProjectADMOrBadRequest(projectIri) + project <- getProjectOrBadRequest(projectIri) updateUser <- knoraUserService.removeUserFromProject(user, project).mapError(BadRequestException.apply) response <- asExternalUserResponse(requestingUser, updateUser) } yield response @@ -259,7 +255,7 @@ final case class UserRestService( _ <- ensureNotABuiltInUser(userIri) _ <- auth.ensureSystemAdminOrProjectAdminById(requestingUser, projectIri) user <- getKnoraUserOrNotFound(userIri) - project <- getProjectADMOrBadRequest(projectIri) + project <- getProjectOrBadRequest(projectIri) updatedUser <- knoraUserService .removeUserFromProjectAsAdmin(user, project) .mapError(BadRequestException.apply) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala index 70c67bdc2be..3d8324e6f40 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala @@ -210,7 +210,7 @@ object Username extends StringValueCompanion[Username] { } else { UsernameRegex.findFirstIn(value) match { case Some(value) => Right(Username(value)) - case None => Left(UserErrorMessages.UsernameInvalid) + case None => Left(UserErrorMessages.UsernameInvalid + s" $value") } } } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2Module.scala b/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2Module.scala new file mode 100644 index 00000000000..13a255b161f --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2Module.scala @@ -0,0 +1,11 @@ +/* + * 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.api.v2 + +object ApiV2Module { + + val layer = ApiV2ServerEndpoints.layer +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2ServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2ServerEndpoints.scala new file mode 100644 index 00000000000..89f84be1837 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/api/v2/ApiV2ServerEndpoints.scala @@ -0,0 +1,37 @@ +/* + * 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.api.v2 + +import sttp.tapir.ztapir.ZServerEndpoint +import zio.ZLayer + +import org.knora.webapi.slice.lists.api.ListsV2ServerEndpoints +import org.knora.webapi.slice.ontology.api.OntologiesServerEndpoints +import org.knora.webapi.slice.resources.api.ResourceInfoServerEndpoints +import org.knora.webapi.slice.resources.api.ResourcesApiServerEndpoints +import org.knora.webapi.slice.search.api.SearchServerEndpoints +import org.knora.webapi.slice.security.api.AuthenticationServerEndpoints + +class ApiV2ServerEndpoints( + private val authenticationServerEndpoints: AuthenticationServerEndpoints, + private val listsV2ServerEndpoints: ListsV2ServerEndpoints, + private val ontologiesServerEndpoints: OntologiesServerEndpoints, + private val resourceInfoServerEndpoints: ResourceInfoServerEndpoints, + private val resourcesApiServerEndpoints: ResourcesApiServerEndpoints, + private val searchServerEndpoints: SearchServerEndpoints, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = + authenticationServerEndpoints.serverEndpoints ++ + listsV2ServerEndpoints.serverEndpoints ++ + resourceInfoServerEndpoints.serverEndpoints ++ + resourcesApiServerEndpoints.serverEndpoints ++ + searchServerEndpoints.serverEndpoints ++ + ontologiesServerEndpoints.serverEndpoints +} +object ApiV2ServerEndpoints { + val layer = ZLayer.derive[ApiV2ServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala index 39326124a72..49ac7b0d1ad 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala @@ -30,6 +30,8 @@ object KnoraIris { final def toInternalSchema: SmartIri = self.smartIri.toInternalSchema final def toOntologySchema(s: OntologySchema): SmartIri = self.smartIri.toOntologySchema(s) + final def ontologySchema: Option[OntologySchema] = smartIri.getOntologySchema + override def toString: String = self.smartIri.toString def toShortString = s"${self.smartIri.getOntologyName}:${self.smartIri.getEntityName}" diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/ApiV2.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/ApiV2.scala index 146acd5f3eb..3fc60232e91 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/ApiV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/api/ApiV2.scala @@ -7,20 +7,15 @@ package org.knora.webapi.slice.common.api import sttp.model.ContentTypeRange import sttp.model.MediaType -import sttp.tapir.Codec -import sttp.tapir.CodecFormat -import sttp.tapir.DecodeResult -import sttp.tapir.EndpointIO -import sttp.tapir.EndpointInput -import sttp.tapir.extractFromRequest -import sttp.tapir.header -import sttp.tapir.query +import sttp.tapir.* + +import java.nio.charset.StandardCharsets import org.knora.webapi.ApiV2Schema import org.knora.webapi.JsonLdRendering import org.knora.webapi.MarkupRendering import org.knora.webapi.Rendering -import org.knora.webapi.messages.util.rdf.RdfFormat +import org.knora.webapi.messages.util.rdf.* import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions object ApiV2 { @@ -132,7 +127,7 @@ object ApiV2 { private val rdfFormat: EndpointInput.ExtractFromRequest[RdfFormat] = extractFromRequest(_.acceptsContentTypes) .description( s"""With the Accept header the RDF format used for the response can be specified. - |Valid values are: ${RdfFormat.values}. + |Valid values are: ${RdfFormat.values.map(_.mediaType.toString())}. |If not specified or unknown, the fallback RDF format ${RdfFormat.default} will be used.""".stripMargin, ) .mapDecode(s => @@ -153,6 +148,17 @@ object ApiV2 { ) } + object Outputs { + val stringBodyFormatted: EndpointIO.OneOfBody[String, String] = oneOfBody( + stringBodyAnyFormat(Codec.string.format(JsonLD), StandardCharsets.UTF_8), + stringBodyAnyFormat(Codec.string.format(Turtle), StandardCharsets.UTF_8), + stringBodyAnyFormat(Codec.string.format(TriG), StandardCharsets.UTF_8), + stringBodyAnyFormat(Codec.string.format(RdfXml), StandardCharsets.UTF_8), + stringBodyAnyFormat(Codec.string.format(NQuads), StandardCharsets.UTF_8), + ) + val contentTypeHeader: EndpointIO.Header[MediaType] = header[MediaType]("Content-Type") + } + private object Codecs { // Codec for ApiV2Schema implicit val apiV2SchemaListCodec: Codec[List[String], Option[ApiV2Schema], CodecFormat.TextPlain] = @@ -164,4 +170,5 @@ object ApiV2 { implicit val markupRenderingListCode: Codec[List[String], Option[MarkupRendering], CodecFormat.TextPlain] = Codec.listHeadOption(Codec.string.mapEither(MarkupRendering.from)(_.name)) } + } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/BaseEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/BaseEndpoints.scala index ce7a10142a3..4001050c54f 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/BaseEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/api/BaseEndpoints.scala @@ -7,32 +7,24 @@ package org.knora.webapi.slice.common.api import sttp.model.StatusCode import sttp.model.headers.WWWAuthenticateChallenge -import sttp.tapir.Endpoint import sttp.tapir.EndpointOutput -import sttp.tapir.auth -import sttp.tapir.cookie -import sttp.tapir.endpoint +import sttp.tapir.PublicEndpoint +import sttp.tapir.Validator import sttp.tapir.generic.auto.* import sttp.tapir.json.zio.jsonBody import sttp.tapir.model.UsernamePassword -import sttp.tapir.oneOf -import sttp.tapir.oneOfVariant -import sttp.tapir.statusCode -import zio.ZIO -import zio.ZLayer - -import scala.concurrent.Future +import sttp.tapir.ztapir.* +import zio.* import dsp.errors.* import org.knora.webapi.messages.util.KnoraSystemInstances.Users.AnonymousUser -import org.knora.webapi.routing.UnsafeZioRun import org.knora.webapi.slice.admin.domain.model.* import org.knora.webapi.slice.security.Authenticator -final case class BaseEndpoints(authenticator: Authenticator)(implicit val r: zio.Runtime[Any]) { +final case class BaseEndpoints(authenticator: Authenticator) { - private val errorOutputs: EndpointOutput.OneOf[RequestRejectedException, RequestRejectedException] = - oneOf[RequestRejectedException]( + private val errorOutputs: EndpointOutput.OneOf[Throwable, Throwable] = + oneOf[Throwable]( // default oneOfVariant[NotFoundException](statusCode(StatusCode.NotFound).and(jsonBody[NotFoundException])), oneOfVariant[BadRequestException](statusCode(StatusCode.BadRequest).and(jsonBody[BadRequestException])), @@ -50,58 +42,49 @@ final case class BaseEndpoints(authenticator: Authenticator)(implicit val r: zio oneOfVariant[ForbiddenException](statusCode(StatusCode.Forbidden).and(jsonBody[ForbiddenException])), ) - val publicEndpoint = endpoint.errorOut(errorOutputs) - - private val endpointWithBearerCookieBasicAuthOptional - : Endpoint[(Option[String], Option[String], Option[UsernamePassword]), Unit, RequestRejectedException, Unit, Any] = - endpoint - .errorOut(errorOutputs) - .securityIn(auth.bearer[Option[String]](WWWAuthenticateChallenge.bearer)) - .securityIn(cookie[Option[String]](authenticator.calculateCookieName())) - .securityIn(auth.basic[Option[UsernamePassword]](WWWAuthenticateChallenge.basic("realm"))) + val publicEndpoint: PublicEndpoint[Unit, Throwable, Unit, Any] = endpoint.errorOut(errorOutputs) - val securedEndpoint = endpointWithBearerCookieBasicAuthOptional.serverSecurityLogic { - case (Some(jwtToken), _, _) => authenticateJwt(jwtToken) - case (_, Some(cookie), _) => authenticateJwt(cookie) - case (_, _, Some(basic)) => authenticateBasic(basic) - case _ => Future.successful(Left(BadCredentialsException("No credentials provided."))) - } + private type SecurityIn = (Option[String], Option[String], Option[UsernamePassword]) + private val endpointWithBearerCookieBasicAuthOptional = endpoint + .errorOut(errorOutputs) + .securityIn(auth.bearer[Option[String]](WWWAuthenticateChallenge.bearer)) + .securityIn(cookie[Option[String]](authenticator.calculateCookieName())) + .securityIn(auth.basic[Option[UsernamePassword]](WWWAuthenticateChallenge.basic("realm"))) - val withUserEndpoint = endpointWithBearerCookieBasicAuthOptional.serverSecurityLogic { - case (Some(jwtToken), _, _) => authenticateJwt(jwtToken) - case (_, Some(cookie), _) => authenticateJwt(cookie) - case (_, _, Some(basic)) => authenticateBasic(basic) + val securedEndpoint: ZPartialServerEndpoint[Any, SecurityIn, User, Unit, Throwable, Unit, Any] = + endpointWithBearerCookieBasicAuthOptional.zServerSecurityLogic { + case (Some(jwtToken), _, _) => authenticateJwt(jwtToken) + case (_, Some(cookie), _) => authenticateJwt(cookie) + case (_, _, Some(basic)) => authenticateBasic(basic) + case _ => ZIO.fail(BadCredentialsException("No credentials provided.")) + } - case _ => Future.successful(Right(AnonymousUser)) - } + val withUserEndpoint: ZPartialServerEndpoint[Any, SecurityIn, User, Unit, Throwable, Unit, Any] = + endpointWithBearerCookieBasicAuthOptional.zServerSecurityLogic { + case (Some(bearer), _, _) => authenticateJwt(bearer) + case (_, Some(cookie), _) => authenticateJwt(cookie) + case (_, _, Some(basic)) => authenticateBasic(basic) + case _ => ZIO.succeed(AnonymousUser) + } - private def authenticateJwt(jwtToken: String): Future[Either[RequestRejectedException, User]] = - UnsafeZioRun.runToFuture( - authenticator.authenticate(jwtToken).orElseFail(BadCredentialsException("Invalid credentials.")).either, - ) + private def authenticateJwt(token: String): IO[BadCredentialsException, User] = + authenticator.authenticate(token).orElseFail(BadCredentialsException("Invalid credentials.")) - private def authenticateBasic(basic: UsernamePassword): Future[Either[RequestRejectedException, User]] = - UnsafeZioRun.runToFuture( - (for { - email <- ZIO - .fromEither(Email.from(basic.username)) - .orElseFail(BadCredentialsException("Invalid credentials, email address expected.")) - password <- ZIO - .fromOption(basic.password) - .orElseFail(BadCredentialsException("Invalid credentials, missing password.")) - userAndJwt <- authenticator - .authenticate(email, password) - .orElseFail(BadCredentialsException("Invalid credentials.")) - (user, _) = userAndJwt - } yield user).either, - ) + private def authenticateBasic(basic: UsernamePassword): IO[BadCredentialsException, User] = + for { + email <- ZIO + .fromEither(Email.from(basic.username)) + .orElseFail(BadCredentialsException("Invalid credentials, email address expected.")) + password <- ZIO + .fromOption(basic.password) + .orElseFail(BadCredentialsException("Invalid credentials, missing password.")) + userAndJwt <- authenticator + .authenticate(email, password) + .orElseFail(BadCredentialsException("Invalid credentials.")) + (user, _) = userAndJwt + } yield user } object BaseEndpoints { - val layer = ZLayer.fromZIO( - for { - auth <- ZIO.service[Authenticator] - r <- ZIO.runtime[Any] - } yield BaseEndpoints(auth)(r), - ) + val layer = ZLayer.derive[BaseEndpoints] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/HandlerMapper.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/HandlerMapper.scala deleted file mode 100644 index 34ad1bb4e1f..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/HandlerMapper.scala +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.common.api - -import sttp.capabilities.pekko.PekkoStreams -import sttp.tapir.Endpoint -import sttp.tapir.model.UsernamePassword -import sttp.tapir.server.PartialServerEndpoint -import sttp.tapir.server.ServerEndpoint.Full -import zio.Task -import zio.ZIO -import zio.ZLayer - -import scala.concurrent.Future - -import dsp.errors.RequestRejectedException -import org.knora.webapi.routing.UnsafeZioRun -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.InputType.SecurityIn - -object InputType { - type SecurityIn = (Option[String], Option[String], Option[UsernamePassword]) -} - -case class PublicEndpointHandler[INPUT, OUTPUT]( - endpoint: Endpoint[Unit, INPUT, RequestRejectedException, OUTPUT, PekkoStreams], - handler: INPUT => Task[OUTPUT], -) - -case class SecuredEndpointHandler[INPUT, OUTPUT]( - endpoint: PartialServerEndpoint[ - SecurityIn, - User, - INPUT, - RequestRejectedException, - OUTPUT, - Any, - Future, - ], - handler: User => INPUT => Task[OUTPUT], -) - -final case class HandlerMapper()(implicit val r: zio.Runtime[Any]) { - - def mapSecuredEndpointHandler[INPUT, OUTPUT]( - handlerAndEndpoint: SecuredEndpointHandler[INPUT, OUTPUT], - ): Full[SecurityIn, User, INPUT, RequestRejectedException, OUTPUT, Any, Future] = - handlerAndEndpoint.endpoint.serverLogic(user => in => runToFuture(handlerAndEndpoint.handler(user)(in))) - - def mapPublicEndpointHandler[INPUT, OUTPUT]( - handlerAndEndpoint: PublicEndpointHandler[INPUT, OUTPUT], - ): Full[Unit, Unit, INPUT, RequestRejectedException, OUTPUT, PekkoStreams, Future] = - handlerAndEndpoint.endpoint.serverLogic[Future](in => runToFuture(handlerAndEndpoint.handler(in))) - - def runToFuture[OUTPUT](zio: Task[OUTPUT]): Future[Either[RequestRejectedException, OUTPUT]] = - UnsafeZioRun.runToFuture(zio.refineOrDie { case e: RequestRejectedException => e }.either) -} - -object HandlerMapper { - val layer = ZLayer.fromZIO(ZIO.runtime[Any].map(HandlerMapper()(_))) -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/TapirToPekkoInterpreter.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/TapirToPekkoInterpreter.scala deleted file mode 100644 index 64ae7448a87..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/TapirToPekkoInterpreter.scala +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.common.api - -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.http.scaladsl.server.Route -import sttp.capabilities.WebSockets -import sttp.capabilities.pekko.PekkoStreams -import sttp.tapir.generic.auto.* -import sttp.tapir.json.zio.jsonBody -import sttp.tapir.server.ServerEndpoint -import sttp.tapir.server.metrics.zio.ZioMetrics -import sttp.tapir.server.model.ValuedEndpointOutput -import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter -import sttp.tapir.server.pekkohttp.PekkoHttpServerOptions -import zio.ZLayer -import zio.json.DeriveJsonCodec -import zio.json.JsonCodec - -import scala.concurrent.ExecutionContext -import scala.concurrent.Future - -final case class TapirToPekkoInterpreter()(system: ActorSystem) { - implicit val executionContext: ExecutionContext = system.dispatcher - private case class GenericErrorResponse(error: String) - private object GenericErrorResponse { - implicit val codec: JsonCodec[GenericErrorResponse] = DeriveJsonCodec.gen[GenericErrorResponse] - } - - private def customizedErrorResponse(m: String): ValuedEndpointOutput[?] = - ValuedEndpointOutput(jsonBody[GenericErrorResponse], GenericErrorResponse(m)) - - private val serverOptions = - PekkoHttpServerOptions.customiseInterceptors - .defaultHandlers(customizedErrorResponse) - .metricsInterceptor(ZioMetrics.default[Future]().metricsInterceptor()) - .notAcceptableInterceptor(None) - .options - - private val interpreter: PekkoHttpServerInterpreter = PekkoHttpServerInterpreter(serverOptions) - - def toRoute(endpoint: ServerEndpoint[PekkoStreams & WebSockets, Future]): Route = - interpreter.toRoute(endpoint) -} - -object TapirToPekkoInterpreter { - val layer = ZLayer.derive[TapirToPekkoInterpreter] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala index ab3564de930..6f1f51206d2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/CacheManager.scala @@ -9,11 +9,7 @@ import org.ehcache.config.CacheConfiguration import org.ehcache.config.builders.CacheConfigurationBuilder import org.ehcache.config.builders.CacheManagerBuilder import org.ehcache.config.builders.ResourcePoolsBuilder -import zio.Ref -import zio.UIO -import zio.ULayer -import zio.ZIO -import zio.ZLayer +import zio.* import scala.reflect.ClassTag @@ -52,8 +48,12 @@ object CacheManager { private def getClassOf[A: ClassTag]: Class[A] = implicitly[ClassTag[A]].runtimeClass.asInstanceOf[Class[A]] val layer: ULayer[CacheManager] = ZLayer.scoped { - val acquire = ZIO.succeed(CacheManagerBuilder.newCacheManagerBuilder().build(true)) - val release = (cm: org.ehcache.CacheManager) => ZIO.succeed(cm.close()) + val acquire = ZIO + .succeed(CacheManagerBuilder.newCacheManagerBuilder().build(true)) + .tap(mgr => ZIO.logInfo("CacheManager created and initialized")) + + val release = (mgr: org.ehcache.CacheManager) => ZIO.succeed(mgr.close()) *> ZIO.logInfo("CacheManager closed") + ZIO .acquireRelease(acquire)(release) .flatMap(mgr => Ref.make(Set.empty[EhCache[_, _]]).map(CacheManager(mgr, _))) 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 56a9e2595e1..c7b859189b8 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 @@ -16,19 +16,15 @@ import zio.http.* import zio.metrics.connectors.MetricsConfig import zio.metrics.connectors.prometheus import zio.metrics.jvm.DefaultJvmMetrics - import org.knora.webapi.config.InstrumentationServerConfig import org.knora.webapi.config.KnoraApi import org.knora.webapi.core.State -import org.knora.webapi.slice.admin.api.AdminApiEndpoints -import org.knora.webapi.slice.common.api.ApiV2Endpoints +import org.knora.webapi.routing.Endpoints import org.knora.webapi.slice.infrastructure.api.PrometheusRoutes -import org.knora.webapi.slice.shacl.api.ShaclEndpoints object MetricsServer { - private val metricsServer - : ZIO[AdminApiEndpoints & ApiV2Endpoints & KnoraApi & ShaclEndpoints & PrometheusRoutes & Server, Nothing, Unit] = + private val metricsServer: ZIO[Endpoints & KnoraApi & PrometheusRoutes & Server, Nothing, Unit] = for { docs <- DocsServer.docsEndpoints.map(endpoints => ZioHttpInterpreter().toHttp(endpoints)) prometheus <- ZIO.service[PrometheusRoutes] @@ -36,28 +32,26 @@ object MetricsServer { _ <- ZIO.never.unit } yield () - type MetricsServerEnv = KnoraApi & State & InstrumentationServerConfig & ApiV2Endpoints & ShaclEndpoints & - AdminApiEndpoints + type MetricsServerEnv = KnoraApi & State & InstrumentationServerConfig & Endpoints val make: ZIO[MetricsServerEnv, Throwable, Unit] = for { - knoraApiConfig <- ZIO.service[KnoraApi] - apiV2Endpoints <- ZIO.service[ApiV2Endpoints] - adminApiEndpoints <- ZIO.service[AdminApiEndpoints] - shaclApiEndpoints <- ZIO.service[ShaclEndpoints] - config <- ZIO.service[InstrumentationServerConfig] - port = config.port - interval = config.interval - metricsConfig = MetricsConfig(interval) - _ <- ZIO.logInfo( - s"Starting api on ${knoraApiConfig.externalKnoraApiBaseUrl}, " + - s"find docs on ${knoraApiConfig.externalProtocol}://${knoraApiConfig.externalHost}:$port/docs", - ) + _ <- ZIO.logInfo("Starting metrics and docs server...") + knoraApiConfig <- ZIO.service[KnoraApi] + endpoints <- ZIO.service[Endpoints] + config <- ZIO.service[InstrumentationServerConfig] + port = config.port + interval = config.interval + metricsConfig = MetricsConfig(interval) + _ <- + ZIO.logInfo( + s"Docs and metrics available at " + + s"${knoraApiConfig.externalProtocol}://${knoraApiConfig.externalHost}:$port/docs & " + + s"${knoraApiConfig.externalProtocol}://${knoraApiConfig.externalHost}:$port/metrics", + ) _ <- metricsServer.provide( ZLayer.succeed(knoraApiConfig), - ZLayer.succeed(adminApiEndpoints), - ZLayer.succeed(apiV2Endpoints), - ZLayer.succeed(shaclApiEndpoints), + ZLayer.succeed(endpoints), Server.defaultWithPort(port), prometheus.publisherLayer, ZLayer.succeed(metricsConfig) >>> prometheus.prometheusLayer, @@ -73,11 +67,8 @@ object DocsServer { val docsEndpoints = for { - config <- ZIO.service[KnoraApi] - apiV2 <- ZIO.serviceWith[ApiV2Endpoints](_.endpoints) - admin <- ZIO.serviceWith[AdminApiEndpoints](_.endpoints) - shacl <- ZIO.serviceWith[ShaclEndpoints](_.endpoints) - allEndpoints = List(apiV2, admin, shacl).flatten + config <- ZIO.service[KnoraApi] + serverEndpoints <- ZIO.serviceWith[Endpoints](_.serverEndpoints) info = Info( title = "DSP-API", version = BuildInfo.version, @@ -87,7 +78,7 @@ object DocsServer { contact = Some(Contact(name = Some("DaSCH"), url = Some("https://www.dasch.swiss/"))), ) } yield SwaggerInterpreter(customiseDocsModel = addServer(config)) - .fromEndpoints[Task](allEndpoints, info) + .fromServerEndpoints[Task](serverEndpoints, info) private def addServer(config: KnoraApi) = (openApi: OpenAPI) => { openApi.copy(servers = diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementEndpoints.scala index a086c8e343c..f27fcc8e7b9 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementEndpoints.scala @@ -9,7 +9,7 @@ import sttp.model.StatusCode import sttp.tapir.* import sttp.tapir.generic.auto.* import sttp.tapir.json.zio.jsonBody -import zio.ZIO +import zio.* import zio.json.DeriveJsonCodec import zio.json.JsonCodec @@ -80,10 +80,8 @@ final case class ManagementEndpoints(baseEndpoints: BaseEndpoints) { .in("start-compaction") .out(jsonBody[String]) .out(statusCode) - - val endpoints: Seq[AnyEndpoint] = List(getVersion, getHealth).map(_.tag("Management")) } object ManagementEndpoints { - val layer = zio.ZLayer.derive[ManagementEndpoints] + val layer = ZLayer.derive[ManagementEndpoints] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementRoutes.scala deleted file mode 100644 index ae75d4bc33d..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementRoutes.scala +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.infrastructure.api - -import sttp.model.StatusCode -import zio.* - -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class ManagementRoutes( - private val endpoint: ManagementEndpoints, - private val restService: ManagementRestService, - private val mapper: HandlerMapper, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - - val routes = ( - List( - PublicEndpointHandler(endpoint.getVersion, _ => ZIO.succeed(VersionResponse.current)), - PublicEndpointHandler(endpoint.getHealth, _ => restService.healthCheck), - ).map(mapper.mapPublicEndpointHandler) - ++ - List(SecuredEndpointHandler(endpoint.postStartCompaction, restService.startCompaction)) - .map(mapper.mapSecuredEndpointHandler) - ).map(tapirToPekko.toRoute) -} -object ManagementRoutes { - val layer = zio.ZLayer.derive[ManagementRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementServerEndpoints.scala new file mode 100644 index 00000000000..b237a80d4be --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/api/ManagementServerEndpoints.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.infrastructure.api + +import sttp.tapir.ztapir.* +import zio.* + +final case class ManagementServerEndpoints( + private val endpoint: ManagementEndpoints, + private val restService: ManagementRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoint.getVersion.zServerLogic(_ => ZIO.succeed(VersionResponse.current)), + endpoint.getHealth.zServerLogic(_ => restService.healthCheck), + endpoint.postStartCompaction.serverLogic(restService.startCompaction), + ).map(_.withTag("Management")) +} +object ManagementServerEndpoints { + val layer = ZLayer.derive[ManagementServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiModule.scala index b158884dadd..f8ec634d5f2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiModule.scala @@ -4,15 +4,14 @@ */ package org.knora.webapi.slice.lists.api + import zio.URLayer import zio.ZLayer import org.knora.webapi.config.AppConfig import org.knora.webapi.responders.admin.ListsResponder 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.lists.api.service.ListsV2RestService object ListsApiModule { self => @@ -20,18 +19,16 @@ object ListsApiModule { self => // format: off AppConfig & BaseEndpoints & - HandlerMapper & KnoraResponseRenderer & - ListsResponder & - TapirToPekkoInterpreter + ListsResponder // format: on - type Provided = ListsApiV2Routes & ListsEndpointsV2 + type Provided = ListsV2ServerEndpoints + val layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( - ListsApiV2Routes.layer, + ListsV2ServerEndpoints.layer, ListsEndpointsV2.layer, - ListsEndpointsV2Handler.layer, ListsV2RestService.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiV2Routes.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiV2Routes.scala deleted file mode 100644 index 92cf82f9a9c..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsApiV2Routes.scala +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.lists.api - -import org.apache.pekko.http.scaladsl.server.Route -import zio.ZLayer - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class ListsApiV2Routes( - private val listsEndpointsV2: ListsEndpointsV2Handler, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - val routes: Seq[Route] = listsEndpointsV2.allHandlers.map(tapirToPekko.toRoute(_)) -} -object ListsApiV2Routes { - val layer = ZLayer.derive[ListsApiV2Routes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2.scala index 16d7f093288..b855fe8fe98 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2.scala @@ -5,7 +5,6 @@ package org.knora.webapi.slice.lists.api -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* import zio.* @@ -38,21 +37,16 @@ final case class ListsEndpointsV2(private val base: BaseEndpoints) { val getV2Lists = base.withUserEndpoint.get .in("v2" / "lists" / listIri) .in(ApiV2.Inputs.formatOptions) - .out(stringBody.example(Examples.listGetResponseV2.format(FormatOptions.default, Examples.appConfig))) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Returns a list (a graph with all list nodes).") val getV2Node = base.withUserEndpoint.get .in("v2" / "node" / listIri) .in(ApiV2.Inputs.formatOptions) - .out(stringBody.example(Examples.nodeGetResponseV2.format(FormatOptions.default, Examples.appConfig))) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Returns a list node.") - - val endpoints: Seq[AnyEndpoint] = Seq( - getV2Lists, - getV2Node, - ).map(_.endpoint.tag("V2 Lists")) } object ListsEndpointsV2 { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2Handler.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2Handler.scala deleted file mode 100644 index 48e04fefd73..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsEndpointsV2Handler.scala +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.lists.api -import sttp.model.MediaType -import zio.* - -import org.knora.webapi.config.AppConfig -import org.knora.webapi.slice.admin.domain.model.ListProperties.ListIri -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.lists.api.service.ListsV2RestService - -final case class ListsEndpointsV2Handler( - private val appConfig: AppConfig, - private val endpoints: ListsEndpointsV2, - private val listsRestService: ListsV2RestService, - private val mapper: HandlerMapper, -) { - val allHandlers = List( - SecuredEndpointHandler(endpoints.getV2Lists, listsRestService.getList), - SecuredEndpointHandler(endpoints.getV2Node, listsRestService.getNode), - ).map(mapper.mapSecuredEndpointHandler) -} - -object ListsEndpointsV2Handler { - val layer = ZLayer.derive[ListsEndpointsV2Handler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsV2ServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsV2ServerEndpoints.scala new file mode 100644 index 00000000000..180edc890a0 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/ListsV2ServerEndpoints.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.lists.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.lists.api.service.ListsV2RestService + +final case class ListsV2ServerEndpoints( + private val endpoints: ListsEndpointsV2, + private val restService: ListsV2RestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getV2Lists.serverLogic(restService.getList), + endpoints.getV2Node.serverLogic(restService.getNode), + ).map(_.endpoint.tag("V2 Lists")) +} + +object ListsV2ServerEndpoints { + val layer = ZLayer.derive[ListsV2ServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/service/ListsV2RestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/service/ListsV2RestService.scala index bb199020117..6d6b747649f 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/lists/api/service/ListsV2RestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/lists/api/service/ListsV2RestService.scala @@ -37,7 +37,7 @@ final case class ListsV2RestService( */ def getList(user: User)(listIri: ListIri, opts: FormatOptions): Task[(RenderedResponse, MediaType)] = listsResponder - .listGetRequestADM(listIri.value) + .listGetRequestADM(listIri) .flatMap(r => ZIO.getOrFailWith(NotFoundException(s"List $listIri not found."))(r.asOpt[ListGetResponseADM])) .map(_.list) .map(ListGetResponseV2(_, user.lang, appConfig.fallbackLanguage)) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpoints.scala index e0f7b2c36b3..2ef0a3f5415 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpoints.scala @@ -5,14 +5,12 @@ package org.knora.webapi.slice.ontology.api -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* import zio.ZLayer import java.time.Instant -import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex import org.knora.webapi.messages.ValuesValidator import org.knora.webapi.slice.admin.api.Codecs.TapirCodec import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri @@ -38,6 +36,7 @@ final case class OntologiesEndpoints(baseEndpoints: BaseEndpoints) { private val ontologyIriPath = path[IriDto].name("ontologyIri") private val propertyIriPath = path[IriDto].name("propertyIri") private val resourceClassIriPath = path[IriDto].name("resourceClassIri") + private val classIriPath = path[IriDto].name("classIri") private val lastModificationDate = query[LastModificationDate]("lastModificationDate") private val allLanguages = query[Boolean]("allLanguages").default(false) @@ -46,8 +45,8 @@ final case class OntologiesEndpoints(baseEndpoints: BaseEndpoints) { .in(allLanguages) .in(ApiV2.Inputs.formatOptions) .in(extractFromRequest(_.uri)) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description( "This is the route used to dereference an actual ontology IRI. " + "If the URL path looks like it belongs to a built-in API ontology (which has to contain \"knora-api\"), prefix it with http://api.knora.org to get the ontology IRI. " + @@ -58,95 +57,62 @@ final case class OntologiesEndpoints(baseEndpoints: BaseEndpoints) { .in(base / "metadata") .in(header[Option[ProjectIri]](ApiV2.Headers.xKnoraAcceptProject)) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Get the metadata of an ontology") val putOntologiesMetadata = baseEndpoints.securedEndpoint.put .in(base / "metadata") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Change the metadata of an ontology") val getOntologiesMetadataProjects = baseEndpoints.publicEndpoint.get .in(base / "metadata" / paths.description("projectIris")) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesAllentities = baseEndpoints.withUserEndpoint.get .in(base / "allentities" / ontologyIriPath) .in(allLanguages) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Get all entities of an ontology") val postOntologiesClasses = baseEndpoints.withUserEndpoint.post .in(base / "classes") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Create a new class") val putOntologiesClasses = baseEndpoints.withUserEndpoint.put .in(base / "classes") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Change the labels or comments of a class") val deleteOntologiesClassesComment = baseEndpoints.withUserEndpoint.delete .in(base / "classes" / "comment" / resourceClassIriPath) .in(lastModificationDate) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Delete the comment of a class definition.") val postOntologiesCardinalities = baseEndpoints.withUserEndpoint.post .in(base / "cardinalities") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out( - stringBody - .example( - s""" - |{ - | "@id" : "ONTOLOGY_IRI", - | "@type" : "owl:Ontology", - | "knora-api:lastModificationDate" : { - | "@type" : "xsd:dateTimeStamp", - | "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" - | }, - | "@graph" : [ - | { - | "@id" : "CLASS_IRI", - | "@type" : "owl:Class", - | "rdfs:subClassOf" : { - | "@type": "owl:Restriction", - | "OWL_CARDINALITY_PREDICATE": "OWL_CARDINALITY_VALUE", - | "owl:onProperty": { - | "@id" : "PROPERTY_IRI" - | } - | } - | } - | ], - | "@context" : { - | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", - | "owl" : "http://www.w3.org/2002/07/owl#", - | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", - | "xsd" : "http://www.w3.org/2001/XMLSchema#" - | } - |} - |""".stripMargin, - ), - ) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description( "Add cardinalities to a class. " + "For more info check out the documentation.", @@ -164,13 +130,8 @@ final case class OntologiesEndpoints(baseEndpoints: BaseEndpoints) { .example(Some(Cardinality.AtLeastOne.toString)), ) .in(ApiV2.Inputs.formatOptions) - .out(stringBody.example(s""" - |{ - | "${KnoraApiV2Complex.CanDo}": false, - | "${KnoraApiV2Complex.CannotDoReason}": "The new cardinality is not included in the cardinality of a super-class.", - |} - |""".stripMargin)) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description( "If only a class IRI is provided, this endpoint checks if any cardinality of any of the class properties can " + "be replaced. " + @@ -184,157 +145,124 @@ final case class OntologiesEndpoints(baseEndpoints: BaseEndpoints) { .in(base / "cardinalities") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postOntologiesCandeletecardinalities = baseEndpoints.withUserEndpoint.post .in(base / "candeletecardinalities") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val patchOntologiesCardinalities = baseEndpoints.withUserEndpoint.patch .in(base / "cardinalities") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val putOntologiesGuiorder = baseEndpoints.withUserEndpoint.put .in(base / "guiorder") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesClassesIris = baseEndpoints.withUserEndpoint.get - .in(base / "classes" / paths) + .in(base / "classes" / classIriPath) .in(allLanguages) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesCandeleteclass = baseEndpoints.withUserEndpoint.get .in(base / "candeleteclass" / resourceClassIriPath) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val deleteOntologiesClasses = baseEndpoints.withUserEndpoint.delete .in(base / "classes" / resourceClassIriPath) .in(lastModificationDate) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val deleteOntologiesComment = baseEndpoints.withUserEndpoint.delete .in(base / "comment" / ontologyIriPath) .in(lastModificationDate) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postOntologiesProperties = baseEndpoints.withUserEndpoint.post .in(base / "properties") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val putOntologiesProperties = baseEndpoints.withUserEndpoint.put .in(base / "properties") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val deletePropertiesComment = baseEndpoints.withUserEndpoint.delete .in(base / "properties" / "comment" / propertyIriPath) .in(lastModificationDate) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val putOntologiesPropertiesGuielement = baseEndpoints.withUserEndpoint.put .in(base / "properties" / "guielement") .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesProperties = baseEndpoints.withUserEndpoint.get - .in(base / "properties" / paths) + .in(base / "properties" / propertyIriPath) .in(allLanguages) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesCandeleteproperty = baseEndpoints.withUserEndpoint.get .in(base / "candeleteproperty" / propertyIriPath) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val deleteOntologiesProperty = baseEndpoints.securedEndpoint.delete .in(base / "properties" / propertyIriPath) .in(ApiV2.Inputs.formatOptions) .in(lastModificationDate) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postOntologies = baseEndpoints.securedEndpoint.post .in(base) .in(stringJsonBody) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getOntologiesCandeleteontology = baseEndpoints.securedEndpoint .in(base / "candeleteontology" / ontologyIriPath) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val deleteOntologies = baseEndpoints.securedEndpoint.delete .in(base / ontologyIriPath) .in(ApiV2.Inputs.formatOptions) .in(lastModificationDate) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) - - val endpoints = ( - Seq( - getOntologiesMetadataProject, - getOntologiesMetadataProjects, - ) ++ Seq( - getOntologyPathSegments, - putOntologiesMetadata, - getOntologiesAllentities, - postOntologiesClasses, - putOntologiesClasses, - deleteOntologiesClassesComment, - postOntologiesCardinalities, - getOntologiesCanreplacecardinalities, - putOntologiesCardinalities, - postOntologiesCandeletecardinalities, - patchOntologiesCardinalities, - putOntologiesGuiorder, - getOntologiesClassesIris, - getOntologiesCandeleteclass, - deleteOntologiesClasses, - deleteOntologiesComment, - postOntologiesProperties, - putOntologiesProperties, - deletePropertiesComment, - putOntologiesPropertiesGuielement, - getOntologiesProperties, - deleteOntologiesProperty, - postOntologies, - getOntologiesCandeleteontology, - deleteOntologies, - ).map(_.endpoint) - ).map(_.tag("V2 Ontologies")) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) } object OntologiesEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpointsHandler.scala deleted file mode 100644 index a4fad72d746..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesEndpointsHandler.scala +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.ontology.api -import zio.ZLayer - -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.ontology.api.service.OntologiesRestService - -final class OntologiesEndpointsHandler( - private val endpoints: OntologiesEndpoints, - private val restService: OntologiesRestService, - private val mapper: HandlerMapper, -) { - - private val publicHandlers = Seq( - PublicEndpointHandler(endpoints.getOntologiesMetadataProject, restService.getOntologyMetadataByProjectOption), - PublicEndpointHandler(endpoints.getOntologiesMetadataProjects, restService.getOntologyMetadataByProjects), - ).map(mapper.mapPublicEndpointHandler) - - private val secureHandlers = Seq( - SecuredEndpointHandler(endpoints.getOntologyPathSegments, restService.dereferenceOntologyIri), - SecuredEndpointHandler(endpoints.putOntologiesMetadata, restService.changeOntologyMetadata), - SecuredEndpointHandler(endpoints.getOntologiesAllentities, restService.getOntologyEntities), - SecuredEndpointHandler(endpoints.postOntologiesClasses, restService.createClass), - SecuredEndpointHandler(endpoints.putOntologiesClasses, restService.changeClassLabelsOrComments), - SecuredEndpointHandler(endpoints.deleteOntologiesClassesComment, restService.deleteClassComment), - SecuredEndpointHandler(endpoints.postOntologiesCardinalities, restService.addCardinalities), - SecuredEndpointHandler(endpoints.getOntologiesCanreplacecardinalities, restService.canChangeCardinality), - SecuredEndpointHandler(endpoints.putOntologiesCardinalities, restService.replaceCardinalities), - SecuredEndpointHandler(endpoints.postOntologiesCandeletecardinalities, restService.canDeleteCardinalitiesFromClass), - SecuredEndpointHandler(endpoints.patchOntologiesCardinalities, restService.deleteCardinalitiesFromClass), - SecuredEndpointHandler(endpoints.putOntologiesGuiorder, restService.changeGuiOrder), - SecuredEndpointHandler(endpoints.getOntologiesClassesIris, restService.getClasses), - SecuredEndpointHandler(endpoints.getOntologiesCandeleteclass, restService.canDeleteClass), - SecuredEndpointHandler(endpoints.deleteOntologiesClasses, restService.deleteClass), - SecuredEndpointHandler(endpoints.deleteOntologiesComment, restService.deleteOntologyComment), - SecuredEndpointHandler(endpoints.postOntologiesProperties, restService.createProperty), - SecuredEndpointHandler(endpoints.putOntologiesProperties, restService.changePropertyLabelsOrComments), - SecuredEndpointHandler(endpoints.deletePropertiesComment, restService.deletePropertyComment), - SecuredEndpointHandler(endpoints.putOntologiesPropertiesGuielement, restService.changePropertyGuiElement), - SecuredEndpointHandler(endpoints.getOntologiesProperties, restService.getProperties), - SecuredEndpointHandler(endpoints.getOntologiesCandeleteproperty, restService.canDeleteProperty), - SecuredEndpointHandler(endpoints.deleteOntologiesProperty, restService.deleteProperty), - SecuredEndpointHandler(endpoints.postOntologies, restService.createOntology), - SecuredEndpointHandler(endpoints.getOntologiesCandeleteontology, restService.canDeleteOntology), - SecuredEndpointHandler(endpoints.deleteOntologies, restService.deleteOntology), - ).map(mapper.mapSecuredEndpointHandler) - - val allHandlers = publicHandlers ++ secureHandlers -} - -object OntologiesEndpointsHandler { - val layer = ZLayer.derive[OntologiesEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesServerEndpoints.scala new file mode 100644 index 00000000000..6dd6f496277 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologiesServerEndpoints.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.ontology.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.domain.model.User +import org.knora.webapi.slice.ontology.api.service.OntologiesRestService + +final class OntologiesServerEndpoints( + private val endpoints: OntologiesEndpoints, + private val restService: OntologiesRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + // GET + endpoints.getOntologiesMetadataProject.zServerLogic(restService.getOntologyMetadataByProjectOption), + endpoints.getOntologiesMetadataProjects.zServerLogic(restService.getOntologyMetadataByProjects), + endpoints.getOntologyPathSegments.serverLogic(restService.dereferenceOntologyIri), + endpoints.getOntologiesAllentities.serverLogic(restService.getOntologyEntities), + endpoints.getOntologiesCanreplacecardinalities.serverLogic(restService.canChangeCardinality), + endpoints.getOntologiesClassesIris.serverLogic(restService.findClassByIri), + endpoints.getOntologiesCandeleteclass.serverLogic(restService.canDeleteClass), + endpoints.getOntologiesProperties.serverLogic(restService.findPropertyByIri), + endpoints.getOntologiesCandeleteproperty.serverLogic(restService.canDeleteProperty), + endpoints.getOntologiesCandeleteontology.serverLogic(restService.canDeleteOntology), + // DELETE + endpoints.deleteOntologiesClassesComment.serverLogic(restService.deleteClassComment), + endpoints.deleteOntologiesClasses.serverLogic(restService.deleteClass), + endpoints.deleteOntologiesComment.serverLogic(restService.deleteOntologyComment), + endpoints.deletePropertiesComment.serverLogic(restService.deletePropertyComment), + endpoints.deleteOntologiesProperty.serverLogic(restService.deleteProperty), + endpoints.deleteOntologies.serverLogic(restService.deleteOntology), + // PATCH + endpoints.patchOntologiesCardinalities.serverLogic(restService.deleteCardinalitiesFromClass), + // POST + endpoints.postOntologiesClasses.serverLogic(restService.createClass), + endpoints.postOntologiesCardinalities.serverLogic(restService.addCardinalities), + endpoints.postOntologiesCandeletecardinalities.serverLogic(restService.canDeleteCardinalitiesFromClass), + endpoints.postOntologiesProperties.serverLogic(restService.createProperty), + endpoints.postOntologies.serverLogic(restService.createOntology), + // PUT + endpoints.putOntologiesMetadata.serverLogic(restService.changeOntologyMetadata), + endpoints.putOntologiesClasses.serverLogic(restService.changeClassLabelsOrComments), + endpoints.putOntologiesCardinalities.serverLogic(restService.replaceCardinalities), + endpoints.putOntologiesGuiorder.serverLogic(restService.changeGuiOrder), + endpoints.putOntologiesProperties.serverLogic(restService.changePropertyLabelsOrComments), + endpoints.putOntologiesPropertiesGuielement.serverLogic(restService.changePropertyGuiElement), + ).map(_.withTag("V2 Ontologies")) +} +object OntologiesServerEndpoints { + val layer = ZLayer.derive[OntologiesServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiModule.scala index 2746a654436..a9811cd088e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiModule.scala @@ -11,9 +11,7 @@ import org.knora.webapi.messages.StringFormatter import org.knora.webapi.responders.v2.OntologyResponderV2 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.service.IriConverter import org.knora.webapi.slice.ontology.api.service.OntologiesRestService import org.knora.webapi.slice.ontology.api.service.RestCardinalityService @@ -29,25 +27,22 @@ object OntologyApiModule { self => AppConfig & BaseEndpoints & CardinalityService & - HandlerMapper & IriConverter & KnoraResponseRenderer & OntologyCacheHelpers & OntologyRepo & OntologyResponderV2 & - StringFormatter & - TapirToPekkoInterpreter + StringFormatter // format: on - type Provided = OntologiesApiRoutes & OntologiesEndpoints & OntologyV2RequestParser + type Provided = OntologiesServerEndpoints & OntologyV2RequestParser val layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( RestCardinalityService.layer, OntologiesRestService.layer, - OntologiesEndpointsHandler.layer, + OntologiesServerEndpoints.layer, OntologiesEndpoints.layer, OntologyV2RequestParser.layer, - OntologiesApiRoutes.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiRoutes.scala deleted file mode 100644 index ed5d7e2e441..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/OntologyApiRoutes.scala +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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.ontology.api -import zio.ZLayer - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class OntologiesApiRoutes( - private val handler: OntologiesEndpointsHandler, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - val routes = handler.allHandlers.map(tapirToPekko.toRoute(_)) -} - -object OntologiesApiRoutes { - val layer = ZLayer.derive[OntologiesApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/service/OntologiesRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/service/OntologiesRestService.scala index 71b599e64c9..c10e54bfc0e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/service/OntologiesRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/api/service/OntologiesRestService.scala @@ -11,6 +11,7 @@ import zio.* import dsp.errors.BadRequestException import dsp.errors.NotFoundException import org.knora.webapi.ApiV2Schema +import org.knora.webapi.OntologySchema import org.knora.webapi.config.AppConfig import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.StringFormatter @@ -94,10 +95,8 @@ final case class OntologiesRestService( projectIris: List[String], formatOptions: FormatOptions, ): Task[(RenderedResponse, MediaType)] = ZIO - .foreach(projectIris.toSet)(iri => - ZIO.fromEither(ProjectIri.from(iri)).orElseFail(BadRequestException(s"Invalid project IRI $iri")), - ) - .flatMap(getOntologyMetadataBy(_, formatOptions)) + .foreach(projectIris.toSet)(iri => ZIO.fromEither(ProjectIri.from(iri)).mapError(BadRequestException.apply)) + .flatMap(projectIris => getOntologyMetadataBy(projectIris, formatOptions)) private def getOntologyMetadataBy(projectIris: Set[ProjectIri], formatOptions: FormatOptions) = for { result <- ontologyResponder.getOntologyMetadataForProjects(projectIris) @@ -214,26 +213,16 @@ final case class OntologiesRestService( response <- renderer.render(result, formatOptions) } yield response - def getClasses(user: User)( - classIris: List[String], + def findClassByIri(user: User)( + classIriDto: IriDto, allLanguages: Boolean, formatOptions: FormatOptions, ) = for { - classIris <- ZIO.foreach(classIris)(iriConverter.asResourceClassIri(_).mapError(BadRequestException.apply)) - classIrisWithoutSchema = classIris.filter(_.smartIri.getOntologySchema.isEmpty) - _ <- ZIO.fail { - BadRequestException(s"Class IRIs found without ontology schema: ${classIrisWithoutSchema.mkString(", ")}") - }.unless(classIrisWithoutSchema.isEmpty) - ontologyIris = classIris.map(_.ontologyIri).toSet - _ <- ontologyIris.size match - case 1 => - ZIO.fail(BadRequestException("Only external ontologies may be queried")).when(ontologyIris.head.isInternal) - case _ => ZIO.fail(BadRequestException("Only one ontology may be queried at once")) - schemas = ontologyIris.flatMap(_.smartIri.getOntologySchema).collect { case schema: ApiV2Schema => schema } - schema <- schemas.size match - case 1 => ZIO.succeed(schemas.head) - case _ => ZIO.fail(BadRequestException("Only one ontology schema may be queried at once")) - result <- ontologyCacheHelpers.getClasses(classIris, allLanguages, user) + classIri <- iriConverter.asResourceClassIri(classIriDto.value).mapError(BadRequestException.apply) + schema <- ZIO + .fromOption(classIri.ontologySchema.collect { case s: ApiV2Schema => s }) + .orElseFail(BadRequestException(s"Class IRI must have an API V2 schema: $classIriDto")) + result <- ontologyCacheHelpers.getClassAsReadOntologyV2(classIri, allLanguages, user) response <- renderer.render(result, formatOptions.copy(schema = schema)) } yield response @@ -317,22 +306,17 @@ final case class OntologiesRestService( response <- renderer.render(result, formatOptions) } yield response - def getProperties(user: User)( - propertyIris: List[String], + def findPropertyByIri(user: User)( + propertyIri: IriDto, allLanguages: Boolean, formatOptions: FormatOptions, ): Task[(RenderedResponse, MediaType)] = for { - propertyIris <- ZIO.foreach(propertyIris.toSet)(iriConverter.asPropertyIri).mapError(BadRequestException.apply) - _ <- ZIO - .fail(BadRequestException(s"Only one ontology may be queried at once")) - .when(propertyIris.map(_.ontologyIri).size != 1) - schemasFromProperties = propertyIris.flatMap(_.smartIri.getOntologySchema) - formatOptions <- ZIO - .fail(BadRequestException(s"Only one ontology schema may be queried at once")) - .when(schemasFromProperties.size != 1) - .as(formatOptions.copy(schema = schemasFromProperties.head.asInstanceOf[ApiV2Schema])) - result <- ontologyResponder.getPropertiesFromOntologyV2(propertyIris, allLanguages, user) - response <- renderer.render(result, formatOptions) + propertyIri <- iriConverter.asPropertyIri(propertyIri.value).mapError(BadRequestException.apply) + schema <- ZIO + .fromOption(propertyIri.ontologySchema.collect { case s: ApiV2Schema => s }) + .orElseFail(BadRequestException(s"Property IRI must have an API V2 schema: $propertyIri")) + result <- ontologyResponder.getPropertyFromOntologyV2(propertyIri, allLanguages, user) + response <- renderer.render(result, formatOptions.copy(schema = schema)) } yield response def canDeleteProperty( diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyCacheHelpers.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyCacheHelpers.scala index 7d24629ca99..1ecaa473ae8 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyCacheHelpers.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyCacheHelpers.scala @@ -25,12 +25,12 @@ import org.knora.webapi.slice.ontology.repo.service.OntologyCache final case class OntologyCacheHelpers(ontologyCache: OntologyCache, ontologyRepo: OntologyRepo) { - def getClasses( - classIris: Seq[ResourceClassIri], + def getClassAsReadOntologyV2( + classIri: ResourceClassIri, allLanguages: Boolean, requestingUser: User, ): Task[ReadOntologyV2] = - getClassDefinitionsFromOntologyV2(classIris.map(_.smartIri).toSet, allLanguages, requestingUser) + getClassDefinitionsFromOntologyV2(Set(classIri.smartIri), allLanguages, requestingUser) /** * Requests information about OWL classes in a single ontology. diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataEndpoints.scala index ed017e6f92b..45ac23f6871 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataEndpoints.scala @@ -8,15 +8,12 @@ package org.knora.webapi.slice.resources.api import sttp.model.MediaType import sttp.tapir.* import sttp.tapir.Codec.PlainCodec -import sttp.tapir.server.PartialServerEndpoint import zio.ZLayer import zio.json.DeriveJsonCodec import zio.json.JsonCodec import java.time.Instant -import scala.concurrent.Future -import dsp.errors.RequestRejectedException import org.knora.webapi.slice.admin.api.AdminPathVariables import org.knora.webapi.slice.admin.api.AdminPathVariables.projectShortcode import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode @@ -105,10 +102,6 @@ final case class MetadataEndpoints(private val baseEndpoints: BaseEndpoints) { "The metadata is returned with complex schema IRIs in the payload. " + "This endpoint is only available for system and project admins.", ) - - val endpoints: Seq[AnyEndpoint] = (Seq( - getResourcesMetadata, - ).map(_.endpoint)).map(_.tag("V2 Metadata")) } object MetadataEndpoints { val layer = ZLayer.derive[MetadataEndpoints] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataServerEndpoints.scala index 507df567cc0..43530156a76 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataServerEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/MetadataServerEndpoints.scala @@ -4,20 +4,19 @@ */ package org.knora.webapi.slice.resources.api -import zio.ZLayer -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler +import sttp.tapir.ztapir.* +import zio.* + import org.knora.webapi.slice.resources.api.service.MetadataRestService final case class MetadataServerEndpoints( private val endpoints: MetadataEndpoints, - private val resourcesRestService: MetadataRestService, - private val mapper: HandlerMapper, + private val restService: MetadataRestService, ) { - val allHandlers = - Seq(SecuredEndpointHandler(endpoints.getResourcesMetadata, resourcesRestService.getResourcesMetadata)) - .map(mapper.mapSecuredEndpointHandler) + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getResourcesMetadata.serverLogic(restService.getResourcesMetadata), + ).map(_.withTag("V2 Metadata")) } object MetadataServerEndpoints { val layer = ZLayer.derive[MetadataServerEndpoints] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoEndpoints.scala index 8092a2babf9..8cc000fe289 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoEndpoints.scala @@ -25,9 +25,6 @@ final case class ResourceInfoEndpoints(baseEndpoints: BaseEndpoints) { .in(Order.queryParam) .in(OrderBy.queryParam.default(OrderBy.LastModificationDate)) .out(jsonBody[ListResponseDto]) - - val endpoints: Seq[AnyEndpoint] = - Seq(getResourcesInfo).map(_.tag("V2 Resources")) } object ResourceInfoEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoRoutes.scala deleted file mode 100644 index d6cd2b0883a..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoRoutes.scala +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.resources.api - -import org.apache.pekko.http.scaladsl.server.Route -import zio.ZLayer - -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter -import org.knora.webapi.slice.resources.api.model.ListResponseDto -import org.knora.webapi.slice.resources.api.service.ResourceInfoRestService - -final case class ResourceInfoRoutes( - private val endpoints: ResourceInfoEndpoints, - private val interpreter: TapirToPekkoInterpreter, - private val mapper: HandlerMapper, - private val resourceInfoService: ResourceInfoRestService, -) { - - val routes: Seq[Route] = - List(PublicEndpointHandler(endpoints.getResourcesInfo, resourceInfoService.findByProjectAndResourceClass)) - .map(mapper.mapPublicEndpointHandler) - .map(interpreter.toRoute(_)) -} -object ResourceInfoRoutes { - val layer = ZLayer.derive[ResourceInfoRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoServerEndpoints.scala new file mode 100644 index 00000000000..171de476589 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourceInfoServerEndpoints.scala @@ -0,0 +1,24 @@ +/* + * 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.resources.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.resources.api.service.ResourceInfoRestService + +final case class ResourceInfoServerEndpoints( + private val endpoints: ResourceInfoEndpoints, + private val resourceInfoService: ResourceInfoRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getResourcesInfo.zServerLogic(resourceInfoService.findByProjectAndResourceClass), + ).map(_.withTag("V2 Resources")) +} +object ResourceInfoServerEndpoints { + val layer = ZLayer.derive[ResourceInfoServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiModule.scala index fc4736e1a35..79b5bf9db91 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiModule.scala @@ -17,9 +17,7 @@ import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.common.ApiComplexV2JsonLdRequestParser 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.service.IriConverter import org.knora.webapi.slice.infrastructure.CsvService import org.knora.webapi.slice.resources.api.service.MetadataRestService @@ -38,7 +36,6 @@ object ResourcesApiModule { self => BaseEndpoints & CsvService & GraphRoute & - HandlerMapper & IriConverter & KnoraProjectService & KnoraResponseRenderer & @@ -47,30 +44,32 @@ object ResourcesApiModule { self => ResourcesResponderV2 & SearchResponderV2 & StandoffResponderV2 & - TapirToPekkoInterpreter & ValuesResponderV2 //format: on - type Provided = MetadataEndpoints & ResourceInfoEndpoints & ResourceInfoRoutes & ResourcesApiRoutes & - ResourcesEndpoints & StandoffEndpoints & ValuesEndpoints + type Provided = + // format: off + ResourceInfoServerEndpoints & + ResourcesApiServerEndpoints + //format: on def layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( - ValuesEndpointsHandler.layer, - ValuesEndpoints.layer, - ValuesRestService.layer, - ResourcesEndpoints.layer, - ResourcesEndpointsHandler.layer, - ResourcesRestService.layer, - ResourcesApiRoutes.layer, MetadataEndpoints.layer, - MetadataServerEndpoints.layer, MetadataRestService.layer, + MetadataServerEndpoints.layer, + ResourceInfoEndpoints.layer, + ResourceInfoRestService.layer, + ResourceInfoServerEndpoints.layer, + ResourcesEndpoints.layer, + ResourcesRestService.layer, + ResourcesServerEndpoints.layer, + ResourcesApiServerEndpoints.layer, StandoffEndpoints.layer, - StandoffEndpointsHandler.layer, StandoffRestService.layer, - ResourceInfoRestService.layer, - ResourceInfoEndpoints.layer, - ResourceInfoRoutes.layer, + StandoffServerEndpoints.layer, + ValuesEndpoints.layer, + ValuesRestService.layer, + ValuesServerEndpoints.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiRoutes.scala deleted file mode 100644 index 825dfdb399b..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiRoutes.scala +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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.resources.api -import org.apache.pekko.http.scaladsl.server.Route -import zio.ZLayer - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class ResourcesApiRoutes( - private val metadataEndpoints: MetadataServerEndpoints, - private val resourcesEndpoints: ResourcesEndpointsHandler, - private val standoffEndpoints: StandoffEndpointsHandler, - private val valuesEndpoints: ValuesEndpointsHandler, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - - private val handlers = - valuesEndpoints.allHandlers ++ resourcesEndpoints.allHandlers ++ standoffEndpoints.allHandlers ++ metadataEndpoints.allHandlers - - val routes: Seq[Route] = handlers.map(tapirToPekko.toRoute(_)) -} - -object ResourcesApiRoutes { - val layer = ZLayer.derive[ResourcesApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiServerEndpoints.scala new file mode 100644 index 00000000000..717ae7a1e73 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesApiServerEndpoints.scala @@ -0,0 +1,24 @@ +/* + * 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.resources.api +import sttp.tapir.ztapir.* +import zio.* + +final case class ResourcesApiServerEndpoints( + private val metadataServerEndpoints: MetadataServerEndpoints, + private val resourcesServerEndpoints: ResourcesServerEndpoints, + private val standoffServerEndpoints: StandoffServerEndpoints, + private val valuesServerEndpoints: ValuesServerEndpoints, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = + valuesServerEndpoints.serverEndpoints ++ + resourcesServerEndpoints.serverEndpoints ++ + standoffServerEndpoints.serverEndpoints ++ + metadataServerEndpoints.serverEndpoints +} +object ResourcesApiServerEndpoints { + val layer = ZLayer.derive[ResourcesApiServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpoints.scala index ee8195259d6..3fcc228adfc 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpoints.scala @@ -5,18 +5,12 @@ package org.knora.webapi.slice.resources.api -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* -import sttp.tapir.server.PartialServerEndpoint import zio.ZLayer -import scala.concurrent.Future - -import dsp.errors.RequestRejectedException import org.knora.webapi.config.GraphRoute import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.common.api.ApiV2 import org.knora.webapi.slice.common.api.BaseEndpoints import org.knora.webapi.slice.resources.api.model.GraphDirection @@ -57,41 +51,41 @@ final case class ResourcesEndpoints( val getResourcesPreview = baseEndpoints.withUserEndpoint.get .in("v2" / "resourcespreview" / paths) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesIiifManifest = baseEndpoints.withUserEndpoint.get .in(base / "iiifmanifest" / path[IriDto].name("resourceIri")) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesProjectHistoryEvents = baseEndpoints.withUserEndpoint.get .in(base / "projectHistoryEvents" / path[ProjectIri].name("projectIri")) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesHistoryEvents = baseEndpoints.withUserEndpoint.get .in(base / "resourceHistoryEvents" / path[IriDto].name("resourceIri")) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesHistory = baseEndpoints.withUserEndpoint.get .in(base / "history" / path[IriDto].name("resourceIri")) .in(ApiV2.Inputs.formatOptions) .in(startDateQuery) .in(endDateQuery) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResources = baseEndpoints.withUserEndpoint.get .in(base / paths) .in(ApiV2.Inputs.formatOptions) .in(versionQuery) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesParams = baseEndpoints.withUserEndpoint.get .in(base) @@ -100,8 +94,8 @@ final case class ResourcesEndpoints( .in(query[Int]("page").validate(Validator.min(0))) .in(header[ProjectIri](ApiV2.Headers.xKnoraAcceptProject)) .in(ApiV2.Inputs.formatOptions) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesGraph = baseEndpoints.withUserEndpoint.get .in("v2" / "graph" / path[IriDto].name("resourceIri")) @@ -114,8 +108,8 @@ final case class ResourcesEndpoints( ) .in(query[GraphDirection]("direction").default(GraphDirection.default)) .in(query[Option[IriDto]]("excludeProperty")) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesTei = baseEndpoints.withUserEndpoint.get .in("v2" / "tei" / path[IriDto].name("resourceIri")) @@ -123,60 +117,43 @@ final case class ResourcesEndpoints( .in(query[IriDto]("textProperty")) .in(query[Option[IriDto]]("gravsearchTemplateIri")) .in(query[Option[IriDto]]("headerXSLTIri")) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postResourcesErase = baseEndpoints.withUserEndpoint.post .in(base / "erase") .in(ApiV2.Inputs.formatOptions) .in(stringJsonBody) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val getResourcesCanDelete = baseEndpoints.withUserEndpoint.get .in(base / "candelete") .in(ApiV2.Inputs.formatOptions) .in(query[String]("jsonLd")) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postResourcesDelete = baseEndpoints.withUserEndpoint.post .in(base / "delete") .in(ApiV2.Inputs.formatOptions) .in(stringJsonBody) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val postResources = baseEndpoints.withUserEndpoint.post .in(base) .in(ApiV2.Inputs.formatOptions) .in(stringJsonBody) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) val putResources = baseEndpoints.withUserEndpoint.put .in(base) .in(ApiV2.Inputs.formatOptions) .in(stringJsonBody) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) - - val endpoints: Seq[AnyEndpoint] = Seq( - getResourcesIiifManifest, - getResourcesPreview, - getResourcesProjectHistoryEvents, - getResourcesHistoryEvents, - getResourcesHistory, - getResources, - getResourcesParams, - getResourcesGraph, - getResourcesTei, - getResourcesCanDelete, - postResourcesErase, - postResourcesDelete, - postResources, - putResources, - ).map(_.endpoint).map(_.tag("V2 Resources")) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) } object ResourcesEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpointsHandler.scala deleted file mode 100644 index fc6d9946456..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesEndpointsHandler.scala +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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.resources.api -import zio.* - -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.resources.api.service.ResourcesRestService - -final class ResourcesEndpointsHandler( - private val resourcesEndpoints: ResourcesEndpoints, - private val resourcesRestService: ResourcesRestService, - private val mapper: HandlerMapper, -) { - - val allHandlers = - Seq( - SecuredEndpointHandler(resourcesEndpoints.getResourcesCanDelete, resourcesRestService.canDeleteResource), - SecuredEndpointHandler(resourcesEndpoints.getResourcesGraph, resourcesRestService.getResourcesGraph), - SecuredEndpointHandler( - resourcesEndpoints.getResourcesIiifManifest, - resourcesRestService.getResourcesIiifManifest, - ), - SecuredEndpointHandler( - resourcesEndpoints.getResourcesPreview, - resourcesRestService.getResourcesPreview, - ), - SecuredEndpointHandler( - resourcesEndpoints.getResourcesProjectHistoryEvents, - resourcesRestService.getResourcesProjectHistoryEvents, - ), - SecuredEndpointHandler( - resourcesEndpoints.getResourcesHistoryEvents, - resourcesRestService.getResourcesHistoryEvents, - ), - SecuredEndpointHandler(resourcesEndpoints.getResourcesHistory, resourcesRestService.getResourceHistory), - SecuredEndpointHandler( - resourcesEndpoints.getResourcesParams, - resourcesRestService.searchResourcesByProjectAndClass, - ), - SecuredEndpointHandler(resourcesEndpoints.getResources, resourcesRestService.getResources), - SecuredEndpointHandler(resourcesEndpoints.getResourcesTei, resourcesRestService.getResourceAsTeiV2), - SecuredEndpointHandler(resourcesEndpoints.postResourcesErase, resourcesRestService.eraseResource), - SecuredEndpointHandler(resourcesEndpoints.postResourcesDelete, resourcesRestService.deleteResource), - SecuredEndpointHandler(resourcesEndpoints.postResources, resourcesRestService.createResource), - SecuredEndpointHandler(resourcesEndpoints.putResources, resourcesRestService.updateResourceMetadata), - ).map(mapper.mapSecuredEndpointHandler) -} - -object ResourcesEndpointsHandler { - val layer = ZLayer.derive[ResourcesEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesServerEndpoints.scala new file mode 100644 index 00000000000..2e2eac2b554 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ResourcesServerEndpoints.scala @@ -0,0 +1,40 @@ +/* + * 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.resources.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.resources.api.service.ResourcesRestService + +final class ResourcesServerEndpoints( + private val resourcesEndpoints: ResourcesEndpoints, + private val resourcesRestService: ResourcesRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + resourcesEndpoints.getResourcesCanDelete.serverLogic(resourcesRestService.canDeleteResource), + resourcesEndpoints.getResourcesGraph.serverLogic(resourcesRestService.getResourcesGraph), + resourcesEndpoints.getResourcesIiifManifest.serverLogic(resourcesRestService.getResourcesIiifManifest), + resourcesEndpoints.getResourcesPreview.serverLogic(resourcesRestService.getResourcesPreview), + resourcesEndpoints.getResourcesProjectHistoryEvents.serverLogic( + resourcesRestService.getResourcesProjectHistoryEvents, + ), + resourcesEndpoints.getResourcesHistoryEvents.serverLogic(resourcesRestService.getResourcesHistoryEvents), + resourcesEndpoints.getResourcesHistory.serverLogic(resourcesRestService.getResourceHistory), + resourcesEndpoints.getResourcesParams.serverLogic(resourcesRestService.searchResourcesByProjectAndClass), + resourcesEndpoints.getResources.serverLogic(resourcesRestService.getResources), + resourcesEndpoints.getResourcesTei.serverLogic(resourcesRestService.getResourceAsTeiV2), + resourcesEndpoints.postResourcesErase.serverLogic(resourcesRestService.eraseResource), + resourcesEndpoints.postResourcesDelete.serverLogic(resourcesRestService.deleteResource), + resourcesEndpoints.postResources.serverLogic(resourcesRestService.createResource), + resourcesEndpoints.putResources.serverLogic(resourcesRestService.updateResourceMetadata), + ).map(_.endpoint).map(_.withTag("V2 Resources")) +} + +object ResourcesServerEndpoints { + val layer = ZLayer.derive[ResourcesServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpoints.scala index 6cb1bcc1476..b5bd8d07fe3 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpoints.scala @@ -5,7 +5,6 @@ package org.knora.webapi.slice.resources.api -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* import zio.ZLayer @@ -26,9 +25,7 @@ final case class StandoffEndpoints(baseEndpoints: BaseEndpoints) { .in(multipartBody[CreateStandoffMappingForm]) .in(ApiV2.Inputs.formatOptions) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) - - val endpoints: Seq[AnyEndpoint] = Seq(postMapping).map(_.endpoint.tag("V2 Standoff")) + .out(ApiV2.Outputs.contentTypeHeader) } object StandoffEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpointsHandler.scala deleted file mode 100644 index 0c932b8d8dc..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffEndpointsHandler.scala +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.resources.api - -import zio.ZLayer - -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.resources.api.service.StandoffRestService - -final case class StandoffEndpointsHandler( - endpoints: StandoffEndpoints, - standoffRestService: StandoffRestService, - mapper: HandlerMapper, -) { - val allHandlers = Seq(SecuredEndpointHandler(endpoints.postMapping, standoffRestService.createMapping)) - .map(mapper.mapSecuredEndpointHandler) -} - -object StandoffEndpointsHandler { - val layer = ZLayer.derive[StandoffEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffServerEndpoints.scala new file mode 100644 index 00000000000..8e339972436 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/StandoffServerEndpoints.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.resources.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.resources.api.service.StandoffRestService + +final case class StandoffServerEndpoints( + private val endpoints: StandoffEndpoints, + private val restService: StandoffRestService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.postMapping.serverLogic(restService.createMapping), + ).map(_.endpoint.tag("V2 Standoff")) +} +object StandoffServerEndpoints { + val layer = ZLayer.derive[StandoffServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpoints.scala index 56dd0359e18..84bc7fcad7e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpoints.scala @@ -5,7 +5,6 @@ package org.knora.webapi.slice.resources.api -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* import zio.ZLayer @@ -31,7 +30,7 @@ final case class ValuesEndpoints(baseEndpoint: BaseEndpoints) { .in(version) .in(ApiV2.Inputs.formatOptions) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description(linkToValuesDocumentation) val postValues = baseEndpoint.withUserEndpoint.post @@ -42,7 +41,7 @@ final case class ValuesEndpoints(baseEndpoint: BaseEndpoints) { ), ) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description(linkToValuesDocumentation) val putValues = baseEndpoint.withUserEndpoint.put @@ -53,40 +52,31 @@ final case class ValuesEndpoints(baseEndpoint: BaseEndpoints) { ), ) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description(linkToValuesDocumentation) val deleteValues = baseEndpoint.withUserEndpoint.post .in(base / "delete") .in(stringJsonBody.example(ValuesEndpoints.Examples.deleteValue)) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description(linkToValuesDocumentation) val postValuesErase = baseEndpoint.securedEndpoint.post .in(base / "erase") .in(stringJsonBody.example(ValuesEndpoints.Examples.deleteValue)) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description(s"Erase a Value and all of its old versions from the database completely. $linkToValuesDocumentation") val postValuesErasehistory = baseEndpoint.securedEndpoint.post .in(base / "erasehistory") .in(stringJsonBody.example(ValuesEndpoints.Examples.deleteValue)) .out(stringJsonBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.contentTypeHeader) .description( s"Erase all old versions of a Value from the database completely and keep only the latest version. $linkToValuesDocumentation", ) - - val endpoints: Seq[AnyEndpoint] = Seq( - getValue, - postValues, - putValues, - deleteValues, - postValuesErase, - postValuesErasehistory, - ).map(_.endpoint.tag("V2 Values")) } object ValuesEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpointsHandler.scala deleted file mode 100644 index b1875964f06..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesEndpointsHandler.scala +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.resources.api - -import sttp.model.MediaType -import zio.ZLayer - -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.resources.api.model.ValueUuid -import org.knora.webapi.slice.resources.api.model.VersionDate -import org.knora.webapi.slice.resources.api.service.ValuesRestService - -final class ValuesEndpointsHandler( - endpoints: ValuesEndpoints, - valuesRestService: ValuesRestService, - mapper: HandlerMapper, -) { - - val allHandlers = - Seq( - SecuredEndpointHandler(endpoints.getValue, valuesRestService.getValue), - SecuredEndpointHandler(endpoints.postValues, valuesRestService.createValue), - SecuredEndpointHandler(endpoints.putValues, valuesRestService.updateValue), - SecuredEndpointHandler(endpoints.deleteValues, valuesRestService.deleteValue), - SecuredEndpointHandler(endpoints.postValuesErase, valuesRestService.eraseValue), - SecuredEndpointHandler(endpoints.postValuesErasehistory, valuesRestService.eraseValueHistory), - ).map(mapper.mapSecuredEndpointHandler) -} - -object ValuesEndpointsHandler { - val layer = ZLayer.derive[ValuesEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesServerEndpoints.scala new file mode 100644 index 00000000000..223f64f9745 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/resources/api/ValuesServerEndpoints.scala @@ -0,0 +1,32 @@ +/* + * 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.resources.api + +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions +import org.knora.webapi.slice.resources.api.model.ValueUuid +import org.knora.webapi.slice.resources.api.model.VersionDate +import org.knora.webapi.slice.resources.api.service.ValuesRestService + +final class ValuesServerEndpoints( + private val endpoints: ValuesEndpoints, + private val valuesRestService: ValuesRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getValue.serverLogic(valuesRestService.getValue), + endpoints.postValues.serverLogic(valuesRestService.createValue), + endpoints.putValues.serverLogic(valuesRestService.updateValue), + endpoints.deleteValues.serverLogic(valuesRestService.deleteValue), + endpoints.postValuesErase.serverLogic(valuesRestService.eraseValue), + endpoints.postValuesErasehistory.serverLogic(valuesRestService.eraseValueHistory), + ).map(_.endpoint.tag("V2 Values")) +} +object ValuesServerEndpoints { + val layer = ZLayer.derive[ValuesServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchApiRoutes.scala deleted file mode 100644 index 2487a424c76..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchApiRoutes.scala +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.search.api -import org.apache.pekko.http.scaladsl.server.Route -import sttp.model.MediaType -import zio.* - -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions -import org.knora.webapi.slice.common.api.KnoraResponseRenderer.RenderedResponse -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter -import org.knora.webapi.slice.common.service.IriConverter -import org.knora.webapi.slice.search.api.SearchEndpointsInputs.InputIri -import org.knora.webapi.slice.search.api.SearchEndpointsInputs.Offset - -final case class SearchApiRoutes( - private val iriConverter: IriConverter, - private val mapper: HandlerMapper, - private val searchEndpoints: SearchEndpoints, - private val searchRestService: SearchRestService, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - - val routes: Seq[Route] = - Seq( - SecuredEndpointHandler(searchEndpoints.getFullTextSearch, searchRestService.fullTextSearch), - SecuredEndpointHandler(searchEndpoints.getFullTextSearchCount, searchRestService.fullTextSearchCount), - SecuredEndpointHandler(searchEndpoints.getSearchByLabel, searchRestService.searchResourcesByLabelV2), - SecuredEndpointHandler(searchEndpoints.getSearchByLabelCount, searchRestService.searchResourcesByLabelCountV2), - SecuredEndpointHandler(searchEndpoints.postGravsearch, searchRestService.gravsearch), - SecuredEndpointHandler(searchEndpoints.getGravsearch, searchRestService.gravsearch), - SecuredEndpointHandler(searchEndpoints.postGravsearchCount, searchRestService.gravsearchCount), - SecuredEndpointHandler(searchEndpoints.getGravsearchCount, searchRestService.gravsearchCount), - SecuredEndpointHandler(searchEndpoints.getSearchIncomingLinks, searchRestService.searchIncomingLinks), - SecuredEndpointHandler( - searchEndpoints.getSearchStillImageRepresentations, - searchRestService.getSearchStillImageRepresentations, - ), - SecuredEndpointHandler( - searchEndpoints.getSearchStillImageRepresentationsCount, - searchRestService.getSearchStillImageRepresentationsCount, - ), - SecuredEndpointHandler(searchEndpoints.getSearchIncomingRegions, searchRestService.searchIncomingRegions), - ).map(mapper.mapSecuredEndpointHandler).map(tapirToPekko.toRoute) -} -object SearchApiRoutes { - val layer = SearchRestService.layer >+> SearchEndpoints.layer >>> ZLayer.derive[SearchApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchEndpoints.scala index 0fe9307dac4..24bf010a5f2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchEndpoints.scala @@ -8,7 +8,6 @@ package org.knora.webapi.slice.search.api import eu.timepit.refined.api.Refined import eu.timepit.refined.api.RefinedTypeOps import eu.timepit.refined.numeric.Greater -import sttp.model.HeaderNames import sttp.model.MediaType import sttp.tapir.* import sttp.tapir.codec.refined.* @@ -71,16 +70,16 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(stringBody.description(gravsearchDescription)) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources using a Gravsearch query.") val getGravsearch = baseEndpoints.withUserEndpoint.get .in("v2" / "searchextended" / path[String].description(gravsearchDescription)) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources using a Gravsearch query.") val postGravsearchCount = baseEndpoints.withUserEndpoint.post @@ -88,16 +87,16 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(stringBody.description(gravsearchDescription)) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Count resources using a Gravsearch query.") val getGravsearchCount = baseEndpoints.withUserEndpoint.get .in("v2" / "searchextended" / "count" / path[String].description(gravsearchDescription)) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Count resources using a Gravsearch query.") val getSearchIncomingLinks = baseEndpoints.withUserEndpoint.get @@ -105,8 +104,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.offset) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for incoming links using a Gravsearch query with an offset.") val getSearchStillImageRepresentations = baseEndpoints.withUserEndpoint.get @@ -118,8 +117,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.offset) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for StillImageRepresentations using a Gravsearch query with an offset.") val getSearchStillImageRepresentationsCount = baseEndpoints.withUserEndpoint.get @@ -130,8 +129,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { ) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Count SearchStillImageRepresentations using a Gravsearch query.") val getSearchIncomingRegions = baseEndpoints.withUserEndpoint.get @@ -142,8 +141,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.offset) .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for incoming regions using a Gravsearch query with an offset.") val getSearchByLabel = baseEndpoints.withUserEndpoint.get @@ -152,8 +151,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.offset) .in(SearchEndpointsInputs.limitToProject) .in(SearchEndpointsInputs.limitToResourceClass) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources by label.") val getSearchByLabelCount = baseEndpoints.withUserEndpoint.get @@ -161,8 +160,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(ApiV2.Inputs.formatOptions) .in(SearchEndpointsInputs.limitToProject) .in(SearchEndpointsInputs.limitToResourceClass) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources by label.") val getFullTextSearch = baseEndpoints.withUserEndpoint.get @@ -173,8 +172,8 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.limitToResourceClass) .in(SearchEndpointsInputs.limitToStandoffClass) .in(SearchEndpointsInputs.returnFiles) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources by label.") val getFullTextSearchCount = baseEndpoints.withUserEndpoint.get @@ -183,25 +182,9 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .in(SearchEndpointsInputs.limitToProject) .in(SearchEndpointsInputs.limitToResourceClass) .in(SearchEndpointsInputs.limitToStandoffClass) - .out(stringBody) - .out(header[MediaType](HeaderNames.ContentType)) + .out(ApiV2.Outputs.stringBodyFormatted) + .out(ApiV2.Outputs.contentTypeHeader) .description("Search for resources by label.") - - val endpoints: Seq[AnyEndpoint] = - Seq( - postGravsearch, - getGravsearch, - postGravsearchCount, - getGravsearchCount, - getSearchIncomingLinks, - getSearchStillImageRepresentations, - getSearchStillImageRepresentationsCount, - getSearchIncomingRegions, - getSearchByLabel, - getSearchByLabelCount, - getFullTextSearch, - getFullTextSearchCount, - ).map(_.endpoint.tag("V2 Search")) } object SearchEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchServerEndpoints.scala new file mode 100644 index 00000000000..2bc2f6b3444 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/search/api/SearchServerEndpoints.scala @@ -0,0 +1,41 @@ +/* + * 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.search.api +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.common.api.KnoraResponseRenderer.FormatOptions +import org.knora.webapi.slice.common.api.KnoraResponseRenderer.RenderedResponse +import org.knora.webapi.slice.search.api.SearchEndpointsInputs.Offset + +final case class SearchServerEndpoints( + private val searchEndpoints: SearchEndpoints, + private val searchRestService: SearchRestService, +) { + + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + searchEndpoints.getFullTextSearch.serverLogic(searchRestService.fullTextSearch), + searchEndpoints.getFullTextSearchCount.serverLogic(searchRestService.fullTextSearchCount), + searchEndpoints.getSearchByLabel.serverLogic(searchRestService.searchResourcesByLabelV2), + searchEndpoints.getSearchByLabelCount.serverLogic(searchRestService.searchResourcesByLabelCountV2), + searchEndpoints.postGravsearch.serverLogic(searchRestService.gravsearch), + searchEndpoints.getGravsearch.serverLogic(searchRestService.gravsearch), + searchEndpoints.postGravsearchCount.serverLogic(searchRestService.gravsearchCount), + searchEndpoints.getGravsearchCount.serverLogic(searchRestService.gravsearchCount), + searchEndpoints.getSearchIncomingLinks.serverLogic(searchRestService.searchIncomingLinks), + searchEndpoints.getSearchStillImageRepresentations.serverLogic( + searchRestService.getSearchStillImageRepresentations, + ), + searchEndpoints.getSearchStillImageRepresentationsCount.serverLogic( + searchRestService.getSearchStillImageRepresentationsCount, + ), + searchEndpoints.getSearchIncomingRegions.serverLogic(searchRestService.searchIncomingRegions), + ).map(_.endpoint.tag("V2 Search")) +} +object SearchServerEndpoints { + val layer = SearchRestService.layer >+> SearchEndpoints.layer >>> ZLayer.derive[SearchServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiModule.scala index 008b9d40180..e7993e518af 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiModule.scala @@ -10,18 +10,18 @@ import zio.ZLayer import org.knora.webapi.config.AppConfig 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.security.Authenticator object AuthenticationApiModule { self => - type Dependencies = AppConfig & Authenticator & BaseEndpoints & HandlerMapper & TapirToPekkoInterpreter - type Provided = AuthenticationApiRoutes & AuthenticationEndpointsV2 + + type Dependencies = AppConfig & Authenticator & BaseEndpoints + + type Provided = AuthenticationServerEndpoints + val layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( AuthenticationEndpointsV2.layer, - AuthenticationEndpointsV2Handler.layer, - AuthenticationApiRoutes.layer, + AuthenticationServerEndpoints.layer, AuthenticationRestService.layer, ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiRoutes.scala deleted file mode 100644 index 085fcd935b6..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationApiRoutes.scala +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.security.api - -import zio.ZLayer - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final class AuthenticationApiRoutes( - private val handler: AuthenticationEndpointsV2Handler, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - val routes = handler.allHandlers.map(tapirToPekko.toRoute) -} - -object AuthenticationApiRoutes { - val layer = ZLayer.derive[AuthenticationApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2.scala b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2.scala index 3f11ac15d6f..72f263f3e41 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2.scala @@ -54,10 +54,6 @@ case class AuthenticationEndpointsV2( .in(formBody[LoginForm]) .out(setCookie(cookieName)) .out(jsonBody[TokenResponse]) - - val endpoints: Seq[AnyEndpoint] = - Seq(getV2Authentication.endpoint, postV2Authentication, deleteV2Authentication, getV2Login, postV2Login) - .map(_.tag("V2 Authentication")) } object AuthenticationEndpointsV2 { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2Handler.scala b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2Handler.scala deleted file mode 100644 index 861f56d7d44..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationEndpointsV2Handler.scala +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.security.api -import sttp.model.headers.CookieValueWithMeta -import zio.ZIO -import zio.ZLayer - -import org.knora.webapi.config.AppConfig -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler -import org.knora.webapi.slice.common.api.SecuredEndpointHandler -import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.CheckResponse -import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LoginForm -import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LoginPayload -import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LogoutResponse -import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.TokenResponse - -case class AuthenticationEndpointsV2Handler( - appConfig: AppConfig, - restService: AuthenticationRestService, - endpoints: AuthenticationEndpointsV2, - mapper: HandlerMapper, -) { - val allHandlers = List( - SecuredEndpointHandler(endpoints.getV2Authentication, _ => _ => ZIO.succeed(CheckResponse("credentials are OK"))), - ).map(mapper.mapSecuredEndpointHandler) ++ List( - PublicEndpointHandler(endpoints.postV2Authentication, restService.authenticate), - PublicEndpointHandler(endpoints.deleteV2Authentication, restService.logout), - PublicEndpointHandler(endpoints.getV2Login, restService.loginForm), - PublicEndpointHandler(endpoints.postV2Login, restService.authenticate), - ).map(mapper.mapPublicEndpointHandler) -} - -object AuthenticationEndpointsV2Handler { - val layer = ZLayer.derive[AuthenticationEndpointsV2Handler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationServerEndpoints.scala new file mode 100644 index 00000000000..1b5261b7773 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/security/api/AuthenticationServerEndpoints.scala @@ -0,0 +1,29 @@ +/* + * 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.security.api +import sttp.tapir.ztapir.* +import zio.* + +import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.CheckResponse +import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LoginForm +import org.knora.webapi.slice.security.api.AuthenticationEndpointsV2.LoginPayload + +case class AuthenticationServerEndpoints( + private val restService: AuthenticationRestService, + private val endpoints: AuthenticationEndpointsV2, +) { + val serverEndpoints: List[ZServerEndpoint[Any, Any]] = List( + endpoints.getV2Authentication.serverLogic(_ => _ => ZIO.succeed(CheckResponse("credentials are OK"))), + endpoints.postV2Authentication.zServerLogic(restService.authenticate), + endpoints.deleteV2Authentication.zServerLogic(restService.logout), + endpoints.getV2Login.zServerLogic(restService.loginForm), + endpoints.postV2Login.zServerLogic(restService.authenticate), + ).map(_.withTag("V2 Authentication")) +} + +object AuthenticationServerEndpoints { + val layer = ZLayer.derive[AuthenticationServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiModule.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiModule.scala index 47282ab9d2f..333482b1dcc 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiModule.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiModule.scala @@ -8,18 +8,17 @@ package org.knora.webapi.slice.shacl.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.shacl.domain.ShaclValidator object ShaclApiModule { self => - type Dependencies = BaseEndpoints & HandlerMapper & ShaclValidator & TapirToPekkoInterpreter - type Provided = ShaclApiRoutes & ShaclEndpoints - val layer: URLayer[self.Dependencies, self.Provided] = - ZLayer.makeSome[self.Dependencies, self.Provided]( - ShaclApiRoutes.layer, - ShaclEndpoints.layer, - ShaclEndpointsHandler.layer, - ShaclApiService.layer, - ) + + type Dependencies = BaseEndpoints & ShaclValidator + + type Provided = ShaclServerEndpoints + + val layer: URLayer[self.Dependencies, self.Provided] = ZLayer.makeSome[self.Dependencies, self.Provided]( + ShaclEndpoints.layer, + ShaclServerEndpoints.layer, + ShaclApiService.layer, + ) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiRoutes.scala deleted file mode 100644 index b66cebc7256..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiRoutes.scala +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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.shacl.api - -import org.apache.pekko.http.scaladsl.server.Route -import zio.* - -import org.knora.webapi.slice.common.api.TapirToPekkoInterpreter - -final case class ShaclApiRoutes( - private val shaclEndpointsHandler: ShaclEndpointsHandler, - private val tapirToPekko: TapirToPekkoInterpreter, -) { - val routes: Seq[Route] = shaclEndpointsHandler.allHandlers.map(tapirToPekko.toRoute(_)) -} - -object ShaclApiRoutes { - val layer = ZLayer.derive[ShaclApiRoutes] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiService.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiService.scala index 5eff8f3dc19..7f8685971ff 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclApiService.scala @@ -7,46 +7,30 @@ package org.knora.webapi.slice.shacl.api import org.apache.jena.riot.RDFDataMgr import org.apache.jena.riot.RDFFormat -import org.apache.pekko.stream.scaladsl.Source -import org.apache.pekko.stream.scaladsl.StreamConverters -import org.apache.pekko.util.ByteString import zio.* +import zio.stream.* import java.io.FileInputStream -import java.io.OutputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream import org.knora.webapi.slice.shacl.domain.ShaclValidator import org.knora.webapi.slice.shacl.domain.ValidationOptions -final case class ShaclApiService(validator: ShaclValidator) { +final case class ShaclApiService(private val validator: ShaclValidator) { - def validate(formData: ValidationFormData): Task[Source[ByteString, Any]] = { + def validate(formData: ValidationFormData): Task[ZStream[Any, Throwable, Byte]] = { val options = ValidationOptions( formData.validateShapes.getOrElse(ValidationOptions.default.validateShapes), formData.reportDetails.getOrElse(ValidationOptions.default.reportDetails), formData.addBlankNodes.getOrElse(ValidationOptions.default.addBlankNodes), ) - val (out, src) = makeOutputStreamAndSource() ZIO.scoped { for { dataStream <- ZIO.fromAutoCloseable(ZIO.succeed(new FileInputStream(formData.`data.ttl`))) shaclStream <- ZIO.fromAutoCloseable(ZIO.succeed(new FileInputStream(formData.`shacl.ttl`))) report <- validator.validate(dataStream, shaclStream, options) - _ <- ZIO.attemptBlockingIO { - try { RDFDataMgr.write(out, report.getModel, RDFFormat.TURTLE) } - finally { out.close() } - }.forkDaemon - } yield () - }.as(src) - } - - private def makeOutputStreamAndSource(): (OutputStream, Source[ByteString, _]) = { - val outputStream = new PipedOutputStream() - val inputStream = new PipedInputStream(outputStream) - val source = StreamConverters.fromInputStream(() => inputStream) - (outputStream, source) + out = ZStream.fromOutputStreamWriter(RDFDataMgr.write(_, report.getModel, RDFFormat.TURTLE)) + } yield out + } } } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpoints.scala index 75da7363b1e..04536cc431a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpoints.scala @@ -5,18 +5,16 @@ package org.knora.webapi.slice.shacl.api -import org.apache.pekko.stream.scaladsl.Source -import org.apache.pekko.util.ByteString -import sttp.capabilities.pekko.PekkoStreams +import sttp.capabilities.zio.ZioStreams import sttp.model.MediaType import sttp.tapir.* import sttp.tapir.Schema.annotations.description import sttp.tapir.generic.auto.* -import zio.ZLayer +import zio.* +import zio.stream.ZStream import java.io.File -import dsp.errors.RequestRejectedException import org.knora.webapi.slice.common.api.BaseEndpoints case class ValidationFormData( @@ -34,12 +32,12 @@ case class ValidationFormData( case class ShaclEndpoints(baseEndpoints: BaseEndpoints) { - val validate: Endpoint[Unit, ValidationFormData, RequestRejectedException, Source[ByteString, Any], PekkoStreams] = + val validate = baseEndpoints.publicEndpoint.post .in("shacl" / "validate") .description("foo") .in(multipartBody[ValidationFormData]) - .out(streamTextBody(PekkoStreams)(new CodecFormat { + .out(streamTextBody(ZioStreams)(new CodecFormat { override val mediaType: MediaType = MediaType("text", "turtle") }).description(""" |The validation report in Turtle format. @@ -53,9 +51,6 @@ case class ShaclEndpoints(baseEndpoints: BaseEndpoints) { |] . |``` |""".stripMargin)) - - val endpoints: Seq[AnyEndpoint] = - Seq(validate).map(_.tag("Shacl")) } object ShaclEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpointsHandler.scala deleted file mode 100644 index 46944ff5b26..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclEndpointsHandler.scala +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.shacl.api - -import zio.ZLayer - -import org.knora.webapi.slice.common.api.HandlerMapper -import org.knora.webapi.slice.common.api.PublicEndpointHandler - -case class ShaclEndpointsHandler( - private val shaclEndpoints: ShaclEndpoints, - private val shaclApiService: ShaclApiService, - private val mapper: HandlerMapper, -) { - - val allHandlers = - List(PublicEndpointHandler(shaclEndpoints.validate, shaclApiService.validate)).map(mapper.mapPublicEndpointHandler) -} - -object ShaclEndpointsHandler { - val layer = ZLayer.derive[ShaclEndpointsHandler] -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclServerEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclServerEndpoints.scala new file mode 100644 index 00000000000..79e277471c8 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/shacl/api/ShaclServerEndpoints.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.shacl.api + +import sttp.capabilities.zio.ZioStreams +import sttp.tapir.ztapir.* +import zio.* + +case class ShaclServerEndpoints( + private val shaclEndpoints: ShaclEndpoints, + private val shaclApiService: ShaclApiService, +) { + val serverEndpoints: List[ZServerEndpoint[Any, ZioStreams]] = List( + shaclEndpoints.validate.zServerLogic(shaclApiService.validate), + ).map(_.withTag("Shacl")) +} + +object ShaclServerEndpoints { + val layer = ZLayer.derive[ShaclServerEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceLive.scala b/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceLive.scala index 76523002131..3bf317fee21 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/triplestore/impl/TriplestoreServiceLive.scala @@ -224,7 +224,7 @@ case class TriplestoreServiceLive( for { _ <- ZIO.logDebug("==>> Loading Data Start") objects = DefaultRdfData.data.toList.filter(_ => prependDefaults) ++ rdfDataObjects - _ <- ZIO.foreachDiscard(objects)(insertObjectIntoTriplestore) + _ <- ZIO.foreachDiscard(objects.toSet)(insertObjectIntoTriplestore) _ <- ZIO.logDebug("==>> Loading Data End") } yield () diff --git a/webapi/src/main/scala/org/knora/webapi/util/Logger.scala b/webapi/src/main/scala/org/knora/webapi/util/Logger.scala index a1998ea88cf..e756952cc18 100644 --- a/webapi/src/main/scala/org/knora/webapi/util/Logger.scala +++ b/webapi/src/main/scala/org/knora/webapi/util/Logger.scala @@ -4,6 +4,7 @@ */ package org.knora.webapi.util + import zio.* import zio.logging.* import zio.logging.LogFormat.* @@ -19,9 +20,10 @@ object Logger { private val logFilter = LogFilter.LogLevelByNameConfig( rootLogLevel, - ("org.apache.jena", LogLevel.Info), + ("org.apache.jena", LogLevel.Debug), ("io.netty", LogLevel.Info), ("org.ehcache", LogLevel.Info), + ("zio.http.*", LogLevel.Debug), // Uncomment the following lines to change the log level for specific loggers: // ("zio.logging.slf4j", LogLevel.Debug) // ("SLF4J-LOGGER", LogLevel.Warning) @@ -53,5 +55,4 @@ object Logger { def json(): ULayer[Unit] = Runtime.removeDefaultLoggers >>> jsonLogger >+> Slf4jBridge.initialize - val text: ULayer[Unit] = Runtime.removeDefaultLoggers >>> textLogger >+> Slf4jBridge.initialize } diff --git a/webapi/src/main/scala/org/knora/webapi/util/ZioHelper.scala b/webapi/src/main/scala/org/knora/webapi/util/ZioHelper.scala index 9bf4ac5430f..744c3432974 100644 --- a/webapi/src/main/scala/org/knora/webapi/util/ZioHelper.scala +++ b/webapi/src/main/scala/org/knora/webapi/util/ZioHelper.scala @@ -5,6 +5,7 @@ package org.knora.webapi.util +import zio.LogLevel import zio.Task import zio.ZIO @@ -16,4 +17,11 @@ object ZioHelper { def sequence[A](x: Seq[Task[A]]): Task[List[A]] = x.map(_.map(x => List[A](x))) .fold(ZIO.succeed(List.empty[A]))((x, y) => x.flatMap(a => y.map(b => a ++ b))) + + def addLogTiming[R, E, A](msg: String, logLevel: LogLevel = LogLevel.Debug)(zio: ZIO[R, E, A]): ZIO[R, E, A] = + ZIO.logLevel(logLevel) { + zio.timed.flatMap { case (duration, res) => + ZIO.log(s"$msg took: ${duration.toMillis} ms").as(res) + } + } } diff --git a/webapi/src/test/scala/org/knora/webapi/http/version/ServerVersionSpec.scala b/webapi/src/test/scala/org/knora/webapi/http/version/ServerVersionSpec.scala deleted file mode 100644 index aff466ed241..00000000000 --- a/webapi/src/test/scala/org/knora/webapi/http/version/ServerVersionSpec.scala +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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.http.version - -import org.apache.pekko.http.scaladsl.model.headers.Server -import zio.test.Spec -import zio.test.ZIOSpecDefault -import zio.test.assertTrue - -object ServerVersionSpec extends ZIOSpecDefault { - - val spec: Spec[Any, Nothing] = suite("ServerVersionSpec")( - test("The server version header") { - val header: Server = ServerVersion.serverVersionHeader - assertTrue(header.toString.contains("webapi/")) - }, - ) -} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UsernameSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UsernameSpec.scala index 3b681ae61c8..b1284bf1d84 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UsernameSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UsernameSpec.scala @@ -18,6 +18,10 @@ object UsernameSpec extends ZIOSpecDefault { "user-123", "user.123", "use", + "Mose.Dooley", + "Wayne.1576803472", + "Stacey.1811737105", + "a".repeat(50), // (50 characters) ) private val invalidNames = Seq( "_username", // (starts with underscore) @@ -41,7 +45,7 @@ object UsernameSpec extends ZIOSpecDefault { check(Gen.fromIterable(validNames))(it => assertTrue(Username.from(it).map(_.value) == Right(it))) }, test("should reject invalid names") { - check(Gen.fromIterable(invalidNames))(it => assertTrue(Username.from(it) == Left("Username is invalid."))) + check(Gen.fromIterable(invalidNames))(it => assertTrue(Username.from(it) == Left(s"Username is invalid. $it"))) }, ) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/CacheManagerSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/CacheManagerSpec.scala new file mode 100644 index 00000000000..916304ffb80 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/CacheManagerSpec.scala @@ -0,0 +1,38 @@ +/* + * 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.infrastructure + +import zio.* +import zio.test.* +import zio.test.Assertion.* + +object CacheManagerSpec extends ZIOSpecDefault { + + private val cacheManager = ZIO.serviceWithZIO[CacheManager] + + val spec = suite("CacheManagerSpec")( + test("create and use a cache") { + for { + cache <- cacheManager(_.createCache[String, String]("testCache")) + _ <- ZIO.succeed(cache.put("one", "1")) + value <- ZIO.succeed(cache.get("one")) + } yield assertTrue(value.contains("1")) + }, + test("fail to create a cache twice") { + for { + _ <- cacheManager(_.createCache[String, String]("testCache")) + exit <- cacheManager(_.createCache[String, String]("testCache")).exit + } yield assert(exit)(diesWithA[IllegalArgumentException]) + }, + test("when clearing all caches, the entries are removed") { + for { + cache <- cacheManager(_.createCache[String, String]("testCache")) + _ = cache.put("one", "1") + _ <- cacheManager(_.clearAll()) + } yield assertTrue(cache.get("one").isEmpty) + }, + ).provide(CacheManager.layer) +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/shacl/api/ShaclApiServiceSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/shacl/api/ShaclApiServiceSpec.scala index b0871097a08..8aa824f85e4 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/shacl/api/ShaclApiServiceSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/shacl/api/ShaclApiServiceSpec.scala @@ -5,45 +5,25 @@ package org.knora.webapi.slice.shacl.api -import org.apache.pekko.actor.* -import org.apache.pekko.stream.Materializer -import org.apache.pekko.stream.scaladsl.Sink -import org.apache.pekko.stream.scaladsl.Source -import org.apache.pekko.util.ByteString import zio.* import zio.nio.file.Files import zio.test.* -import scala.concurrent.Await -import scala.concurrent.ExecutionContextExecutor -import scala.concurrent.Future - import org.knora.webapi.slice.shacl.domain.ShaclValidator object ShaclApiServiceSpec extends ZIOSpecDefault { private val shaclApiService = ZIO.serviceWithZIO[ShaclApiService] - val spec: Spec[TestEnvironment & zio.Scope, Any] = suite("ShaclApiService")( + val spec = suite("ShaclApiService")( test("validate") { for { - data <- Files.createTempFile("data.ttl", None, Seq.empty) - shacl <- Files.createTempFile("shacl.ttl", None, Seq.empty) - formData = ValidationFormData(data.toFile, shacl.toFile, None, None, None) - result <- shaclApiService(_.validate(formData)) - } yield assertTrue(reportConforms(result)) + data <- Files.createTempFile("data.ttl", None, Seq.empty) + shacl <- Files.createTempFile("shacl.ttl", None, Seq.empty) + formData = ValidationFormData(data.toFile, shacl.toFile, None, None, None) + result <- shaclApiService(_.validate(formData)) + conforms <- result.runCollect.map(bytes => new String(bytes.toArray)).map(_.contains("sh:conforms true")) + } yield assertTrue(conforms) }, ).provide(ShaclApiService.layer, ShaclValidator.layer) - - private def reportConforms(result: Source[ByteString, Any]): Boolean = { - implicit val system: ActorSystem = ActorSystem.create() - implicit val ec: ExecutionContextExecutor = system.dispatcher - implicit val mat: Materializer = Materializer(system) - - val str: Future[String] = result - .runWith(Sink.fold(ByteString.empty)(_ ++ _)) // Accumulate ByteString - .map(_.utf8String) - val reportStr = Await.result(str, 5.seconds.asScala) - reportStr.contains("sh:conforms true") - } }