From 08accabf8b0c5d02fe7147ffe44023878c39d047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Wed, 11 Oct 2023 16:23:07 +0200 Subject: [PATCH] refactor: Introduce tapir on Pekko (#2870) Co-authored-by: Marcin Procyk --- .../org/knora/webapi/core/LayersTest.scala | 8 +- project/Dependencies.scala | 50 ++++--- webapi/src/main/scala/dsp/errors/Errors.scala | 8 +- .../src/main/scala/dsp/valueobjects/Iri.scala | 10 +- .../org/knora/webapi/core/LayersLive.scala | 8 +- .../ProjectsMessagesADM.scala | 3 + .../admin/ProjectsResponderADM.scala | 5 +- .../org/knora/webapi/routing/ApiRoutes.scala | 25 ++-- .../knora/webapi/routing/BaseEndpoints.scala | 35 +++++ .../knora/webapi/routing/HandlerMapperF.scala | 41 ++++++ .../knora/webapi/routing/PathVariables.scala | 56 ++++++++ .../routing/TapirToPekkoInterpreter.scala | 42 ++++++ .../routing/admin/ProjectsEndpoints.scala | 93 +++++++++++++ .../admin/ProjectsEndpointsHandlerF.scala | 90 ++++++++++++ .../routing/admin/ProjectsRouteADM.scala | 131 +++--------------- 15 files changed, 455 insertions(+), 150 deletions(-) create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/BaseEndpoints.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/HandlerMapperF.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/TapirToPekkoInterpreter.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpoints.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpointsHandlerF.scala diff --git a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala index 08be722319..5c70ea8c01 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -29,6 +29,8 @@ import org.knora.webapi.responders.v2.ontology.OntologyHelpers import org.knora.webapi.responders.v2.ontology.OntologyHelpersLive import org.knora.webapi.routing._ import org.knora.webapi.routing.admin.AuthenticatorService +import org.knora.webapi.routing.admin.ProjectsEndpoints +import org.knora.webapi.routing.admin.ProjectsEndpointsHandlerF import org.knora.webapi.routing.admin.ProjectsRouteZ import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive @@ -131,11 +133,13 @@ object LayersTest { private val commonLayersForAllIntegrationTests = ZLayer.makeSome[CommonR0, CommonR]( + HandlerMapperF.layer, ApiRoutes.layer, AppRouter.layer, AuthenticationMiddleware.layer, AuthenticatorLive.layer, AuthenticatorService.layer, + BaseEndpoints.layer, CacheServiceInMemImpl.layer, CacheServiceRequestMessageHandlerLive.layer, CardinalityHandlerLive.layer, @@ -157,6 +161,7 @@ object LayersTest { MessageRelayLive.layer, OntologyCacheLive.layer, OntologyHelpersLive.layer, + OntologyInferencer.layer, OntologyRepoLive.layer, OntologyResponderV2Live.layer, PermissionUtilADMLive.layer, @@ -168,6 +173,8 @@ object LayersTest { ProjectExportStorageServiceLive.layer, ProjectImportServiceLive.layer, ProjectsADMRestServiceLive.layer, + ProjectsEndpoints.layer, + ProjectsEndpointsHandlerF.layer, ProjectsResponderADMLive.layer, ProjectsRouteZ.layer, QueryTraverser.layer, @@ -181,7 +188,6 @@ object LayersTest { RestResourceInfoService.layer, SearchResponderV2Live.layer, SipiResponderADMLive.layer, - OntologyInferencer.layer, StandoffResponderV2Live.layer, StandoffTagUtilV2Live.layer, State.layer, diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 8b165233d6..943f0788bb 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -7,6 +7,8 @@ package org.knora import sbt.* +import scala.collection.immutable.Seq + object Dependencies { val fusekiImage = @@ -30,21 +32,19 @@ object Dependencies { val ZioVersion = "2.0.18" // ZIO - all Scala 3 compatible - val zio = "dev.zio" %% "zio" % ZioVersion - val zioConfig = "dev.zio" %% "zio-config" % ZioConfigVersion - val zioConfigMagnolia = "dev.zio" %% "zio-config-magnolia" % ZioConfigVersion - val zioConfigTypesafe = "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion - val zioHttpOld = "io.d11" %% "zhttp" % ZioHttpVersionOld - val zioHttp = "dev.zio" %% "zio-http" % ZioHttpVersion - val zioJson = "dev.zio" %% "zio-json" % "0.6.2" - val zioLogging = "dev.zio" %% "zio-logging" % ZioLoggingVersion - val zioLoggingSlf4jBridge = "dev.zio" %% "zio-logging-slf4j2-bridge" % ZioLoggingVersion - val zioNio = "dev.zio" %% "zio-nio" % ZioNioVersion - val zioMacros = "dev.zio" %% "zio-macros" % ZioVersion - val zioMetricsConnectors = "dev.zio" %% "zio-metrics-connectors" % ZioMetricsConnectorsVersion - val zioMetricsPrometheusConnector = "dev.zio" %% "zio-metrics-connectors-prometheus" % ZioMetricsConnectorsVersion - val zioPrelude = "dev.zio" %% "zio-prelude" % ZioPreludeVersion - val zioSttp = "com.softwaremill.sttp.client3" %% "zio" % "3.9.0" + val zio = "dev.zio" %% "zio" % ZioVersion + val zioConfig = "dev.zio" %% "zio-config" % ZioConfigVersion + val zioConfigMagnolia = "dev.zio" %% "zio-config-magnolia" % ZioConfigVersion + val zioConfigTypesafe = "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion + val zioHttpOld = "io.d11" %% "zhttp" % ZioHttpVersionOld + val zioHttp = "dev.zio" %% "zio-http" % ZioHttpVersion + val zioJson = "dev.zio" %% "zio-json" % "0.6.2" + val zioLogging = "dev.zio" %% "zio-logging" % ZioLoggingVersion + val zioLoggingSlf4jBridge = "dev.zio" %% "zio-logging-slf4j2-bridge" % ZioLoggingVersion + val zioNio = "dev.zio" %% "zio-nio" % ZioNioVersion + val zioMacros = "dev.zio" %% "zio-macros" % ZioVersion + val zioPrelude = "dev.zio" %% "zio-prelude" % ZioPreludeVersion + val zioSttp = "com.softwaremill.sttp.client3" %% "zio" % "3.9.0" // refined val refined = Seq( @@ -125,6 +125,22 @@ object Dependencies { // found/added by the plugin but deleted anyway val commonsLang3 = "org.apache.commons" % "commons-lang3" % "3.13.0" + val tapirVersion = "1.7.6" + + 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-json-spray" % tapirVersion, + "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion, + "com.softwaremill.sttp.tapir" %% "tapir-refined" % "1.2.10" + ) + val metrics = Seq( + "dev.zio" %% "zio-metrics-connectors" % ZioMetricsConnectorsVersion, + "dev.zio" %% "zio-metrics-connectors-prometheus" % ZioMetricsConnectorsVersion, + "com.softwaremill.sttp.tapir" %% "tapir-zio-metrics" % tapirVersion + ) + val integrationTestDependencies = Seq( pekkoHttpTestkit, pekkoStreamTestkit, @@ -178,10 +194,8 @@ object Dependencies { zioLogging, zioLoggingSlf4jBridge, zioMacros, - zioMetricsConnectors, - zioMetricsPrometheusConnector, zioNio, zioPrelude, zioSttp - ) + ) ++ metrics ++ tapir } diff --git a/webapi/src/main/scala/dsp/errors/Errors.scala b/webapi/src/main/scala/dsp/errors/Errors.scala index 6e4947ce80..28e54a582f 100644 --- a/webapi/src/main/scala/dsp/errors/Errors.scala +++ b/webapi/src/main/scala/dsp/errors/Errors.scala @@ -8,6 +8,8 @@ package dsp.errors import com.typesafe.scalalogging.Logger import org.apache.commons.lang3.SerializationException import org.apache.commons.lang3.SerializationUtils +import zio.json.DeriveJsonCodec +import zio.json.JsonCodec /* @@ -90,6 +92,8 @@ object BadRequestException { BadRequestException(s"Invalid value for query parameter '$key'") def missingQueryParamValue(key: String): BadRequestException = BadRequestException(s"Missing query parameter '$key'") + + implicit val codec: JsonCodec[BadRequestException] = DeriveJsonCodec.gen[BadRequestException] } /** @@ -113,7 +117,9 @@ case class ForbiddenException(message: String) extends RequestRejectedException( */ case class NotFoundException(message: String) extends RequestRejectedException(message) object NotFoundException { - val notFound = NotFoundException("The requested data was not found") + val notFound: NotFoundException = NotFoundException("The requested data was not found") + + implicit val codec: JsonCodec[NotFoundException] = DeriveJsonCodec.gen[NotFoundException] } /** diff --git a/webapi/src/main/scala/dsp/valueobjects/Iri.scala b/webapi/src/main/scala/dsp/valueobjects/Iri.scala index e8b6f8ab3d..d01825550b 100644 --- a/webapi/src/main/scala/dsp/valueobjects/Iri.scala +++ b/webapi/src/main/scala/dsp/valueobjects/Iri.scala @@ -8,6 +8,7 @@ package dsp.valueobjects import com.google.gwt.safehtml.shared.UriUtils.encodeAllowEscapes import org.apache.commons.lang3.StringUtils import org.apache.commons.validator.routines.UrlValidator +import zio.json.JsonCodec import zio.json.JsonDecoder import zio.json.JsonEncoder import zio.prelude.Validation @@ -233,10 +234,11 @@ object Iri { */ sealed abstract case class ProjectIri private (value: String) extends Iri object ProjectIri { self => - implicit val decoder: JsonDecoder[ProjectIri] = - JsonDecoder[String].mapOrFail(value => ProjectIri.make(value).toEitherWith(e => e.head.getMessage)) - implicit val encoder: JsonEncoder[ProjectIri] = - JsonEncoder[String].contramap((projectIri: ProjectIri) => projectIri.value) + + implicit val codec: JsonCodec[ProjectIri] = new JsonCodec[ProjectIri]( + JsonEncoder[String].contramap(_.value), + JsonDecoder[String].mapOrFail(ProjectIri.make(_).toEitherWith(e => e.head.getMessage)) + ) def make(value: String): Validation[ValidationException, ProjectIri] = if (value.isEmpty) Validation.fail(ValidationException(IriErrorMessages.ProjectIriMissing)) 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 93ccaa99fe..c30f2e8c28 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -29,6 +29,8 @@ import org.knora.webapi.responders.v2.ontology.OntologyHelpers import org.knora.webapi.responders.v2.ontology.OntologyHelpersLive import org.knora.webapi.routing._ import org.knora.webapi.routing.admin.AuthenticatorService +import org.knora.webapi.routing.admin.ProjectsEndpoints +import org.knora.webapi.routing.admin.ProjectsEndpointsHandlerF import org.knora.webapi.routing.admin.ProjectsRouteZ import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive @@ -136,6 +138,7 @@ object LayersLive { AuthenticationMiddleware.layer, AuthenticatorLive.layer, AuthenticatorService.layer, + BaseEndpoints.layer, CacheServiceInMemImpl.layer, CacheServiceRequestMessageHandlerLive.layer, CardinalityHandlerLive.layer, @@ -145,6 +148,7 @@ object LayersLive { DspIngestClientLive.layer, GravsearchTypeInspectionRunner.layer, GroupsResponderADMLive.layer, + HandlerMapperF.layer, HttpServer.layer, HttpServerZ.layer, // this is the new ZIO HTTP server layer IIIFRequestMessageHandlerLive.layer, @@ -159,6 +163,7 @@ object LayersLive { MessageRelayLive.layer, OntologyCacheLive.layer, OntologyHelpersLive.layer, + OntologyInferencer.layer, OntologyRepoLive.layer, OntologyResponderV2Live.layer, PermissionUtilADMLive.layer, @@ -170,6 +175,8 @@ object LayersLive { ProjectExportStorageServiceLive.layer, ProjectImportServiceLive.layer, ProjectsADMRestServiceLive.layer, + ProjectsEndpoints.layer, + ProjectsEndpointsHandlerF.layer, ProjectsResponderADMLive.layer, ProjectsRouteZ.layer, QueryTraverser.layer, @@ -183,7 +190,6 @@ object LayersLive { RestResourceInfoService.layer, SearchResponderV2Live.layer, SipiResponderADMLive.layer, - OntologyInferencer.layer, StandoffResponderV2Live.layer, StandoffTagUtilV2Live.layer, State.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala index ff00a67498..5ba7027356 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala @@ -478,6 +478,9 @@ sealed trait ProjectIdentifierADM { self => object ProjectIdentifierADM { + def from(projectIri: ProjectIri): ProjectIdentifierADM = + IriIdentifier(projectIri) + /** * Represents [[IriIdentifier]] identifier. * diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/ProjectsResponderADM.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/ProjectsResponderADM.scala index c846bfb1b6..1205974b6b 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/ProjectsResponderADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/ProjectsResponderADM.scala @@ -6,6 +6,7 @@ package org.knora.webapi.responders.admin import com.typesafe.scalalogging.LazyLogging import zio._ +import zio.macros.accessible import java.util.UUID @@ -48,6 +49,7 @@ import org.knora.webapi.util.ZioHelper /** * Returns information about projects. */ +@accessible trait ProjectsResponderADM { /** @@ -390,8 +392,7 @@ final case class ProjectsResponderADMLive( id <- IriIdentifier.fromString(projectIri.value).toZIO.mapError(e => BadRequestException(e.getMessage)) keywords <- projectService .findProjectKeywordsBy(id) - .flatMap(ZIO.fromOption(_)) - .orElseFail(NotFoundException(s"Project '${projectIri.value}' not found.")) + .someOrFail(NotFoundException(s"Project '${projectIri.value}' not found.")) } yield keywords /** diff --git a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala index 6253aaf810..c6d38e2ddb 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -58,6 +58,7 @@ object ApiRoutes { with KnoraProjectRepo with MessageRelay with ProjectADMRestService + with ProjectsEndpointsHandlerF with RestCardinalityService with RestResourceInfoService with StringFormatter @@ -68,10 +69,12 @@ object ApiRoutes { ] = ZLayer { for { - sys <- ZIO.service[ActorSystem] - router <- ZIO.service[AppRouter] - appConfig <- ZIO.service[AppConfig] - routeData <- ZIO.succeed(KnoraRouteData(sys.system, router.ref, appConfig)) + sys <- ZIO.service[ActorSystem] + router <- ZIO.service[AppRouter] + appConfig <- ZIO.service[AppConfig] + projectsHandler <- ZIO.service[ProjectsEndpointsHandlerF] + routeData <- ZIO.succeed(KnoraRouteData(sys.system, router.ref, appConfig)) + tapirToPekkoRoute = TapirToPekkoInterpreter()(sys.system.dispatcher) runtime <- ZIO.runtime[ AppConfig with IriConverter @@ -85,7 +88,7 @@ object ApiRoutes { with core.State with routing.Authenticator ] - } yield ApiRoutesImpl(routeData, runtime, appConfig) + } yield ApiRoutesImpl(routeData, projectsHandler, tapirToPekkoRoute, appConfig, runtime) } } @@ -97,8 +100,11 @@ object ApiRoutes { * The FIRST matching route is used for handling a request. */ private final case class ApiRoutesImpl( - private val routeData: KnoraRouteData, - private implicit val runtime: Runtime[ + routeData: KnoraRouteData, + projectsHandler: ProjectsEndpointsHandlerF, + tapirToPekkoRoute: TapirToPekkoInterpreter, + appConfig: AppConfig, + implicit val runtime: Runtime[ AppConfig with IriConverter with KnoraProjectRepo @@ -110,8 +116,7 @@ private final case class ApiRoutesImpl( with ValuesResponderV2 with core.State with routing.Authenticator - ], - private val appConfig: AppConfig + ] ) extends ApiRoutes with AroundDirectives { @@ -140,7 +145,7 @@ private final case class ApiRoutesImpl( GroupsRouteADM(routeData, runtime).makeRoute ~ ListsRouteADM(routeData, runtime).makeRoute ~ PermissionsRouteADM(routeData, runtime).makeRoute ~ - ProjectsRouteADM().makeRoute ~ + ProjectsRouteADM(tapirToPekkoRoute, projectsHandler).makeRoute ~ StoreRouteADM(routeData, runtime).makeRoute ~ UsersRouteADM().makeRoute ~ FilesRouteADM(routeData, runtime).makeRoute diff --git a/webapi/src/main/scala/org/knora/webapi/routing/BaseEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/routing/BaseEndpoints.scala new file mode 100644 index 0000000000..f2e38056cb --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/BaseEndpoints.scala @@ -0,0 +1,35 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +import sttp.model.StatusCode +import sttp.tapir.EndpointOutput +import sttp.tapir.endpoint +import sttp.tapir.generic.auto._ +import sttp.tapir.json.zio.jsonBody +import sttp.tapir.oneOf +import sttp.tapir.oneOfVariant +import sttp.tapir.statusCode +import zio.ZLayer + +import dsp.errors.BadRequestException +import dsp.errors.NotFoundException +import dsp.errors.RequestRejectedException + +final case class BaseEndpoints() { + + private val defaultErrorOutputs: EndpointOutput.OneOf[RequestRejectedException, RequestRejectedException] = + oneOf[RequestRejectedException]( + oneOfVariant[NotFoundException](statusCode(StatusCode.NotFound).and(jsonBody[NotFoundException])), + oneOfVariant[BadRequestException](statusCode(StatusCode.BadRequest).and(jsonBody[BadRequestException])) + ) + + val publicEndpoint = endpoint.errorOut(defaultErrorOutputs) +} + +object BaseEndpoints { + val layer = ZLayer.derive[BaseEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/HandlerMapperF.scala b/webapi/src/main/scala/org/knora/webapi/routing/HandlerMapperF.scala new file mode 100644 index 0000000000..579bf465c0 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/HandlerMapperF.scala @@ -0,0 +1,41 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +import sttp.tapir.Endpoint +import sttp.tapir.server.ServerEndpoint.Full +import zio.Task +import zio.ZIO +import zio.ZLayer + +import scala.concurrent.Future + +import dsp.errors.RequestRejectedException + +case class EndpointAndZioHandler[INPUT, OUTPUT]( + endpoint: Endpoint[Unit, INPUT, RequestRejectedException, OUTPUT, Any], + handler: INPUT => Task[OUTPUT] +) + +final case class HandlerMapperF()(implicit val r: zio.Runtime[Any]) { + + def mapEndpointAndHandler[INPUT, OUTPUT]( + handler: EndpointAndZioHandler[INPUT, OUTPUT] + ): Full[Unit, Unit, INPUT, RequestRejectedException, OUTPUT, Any, Future] = + handler.endpoint.serverLogic[Future](handlerFromZio(handler.handler)) + + private def runToFuture[OUTPUT](zio: Task[OUTPUT]): Future[Either[RequestRejectedException, OUTPUT]] = + UnsafeZioRun.runToFuture(zio.refineOrDie { case e: RequestRejectedException => e }.either) + + private def handlerFromZio[INPUT, OUTPUT]( + zio: INPUT => Task[OUTPUT] + ): INPUT => Future[Either[RequestRejectedException, OUTPUT]] = + input => runToFuture(zio.apply(input)) +} + +object HandlerMapperF { + val layer = ZLayer.fromZIO(ZIO.runtime[Any].map(HandlerMapperF()(_))) +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala b/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala new file mode 100644 index 0000000000..be65a5924f --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/PathVariables.scala @@ -0,0 +1,56 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +import sttp.tapir.CodecFormat.TextPlain +import sttp.tapir._ + +import dsp.errors.BadRequestException +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortcodeIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortnameIdentifier + +object PathVariables { + + private val projectIriCodec: Codec[String, IriIdentifier, TextPlain] = + Codec.string.mapDecode(str => + IriIdentifier + .fromString(str) + .fold(err => DecodeResult.Error(str, BadRequestException(err.head.msg)), DecodeResult.Value(_)) + )(_.value.value) + + val projectIri: EndpointInput.PathCapture[IriIdentifier] = + path[IriIdentifier](projectIriCodec) + .name("projectIri") + .description("The IRI of a project. Must be URL-encoded.") + .example(IriIdentifier.fromString("http://rdfh.ch/projects/0001").fold(e => throw e.head, identity)) + + private val projectShortcodeCodec: Codec[String, ShortcodeIdentifier, TextPlain] = + Codec.string.mapDecode(str => + ShortcodeIdentifier + .fromString(str) + .fold(err => DecodeResult.Error(str, BadRequestException(err.head.msg)), DecodeResult.Value(_)) + )(_.value.value) + + val projectShortcode: EndpointInput.PathCapture[ShortcodeIdentifier] = + path[ShortcodeIdentifier](projectShortcodeCodec) + .name("projectShortcode") + .description("The shortcode of a project. Must be a 4 digit hexadecimal String.") + .example(ShortcodeIdentifier.fromString("0001").fold(e => throw e.head, identity)) + + private val projectShortnameCodec: Codec[String, ShortnameIdentifier, TextPlain] = + Codec.string.mapDecode(str => + ShortnameIdentifier + .fromString(str) + .fold(err => DecodeResult.Error(str, BadRequestException(err.head.msg)), DecodeResult.Value(_)) + )(_.value.value) + + val projectShortname: EndpointInput.PathCapture[ShortnameIdentifier] = + path[ShortnameIdentifier](projectShortnameCodec) + .name("projectShortname") + .description("The shortname of a project.") + .example(ShortnameIdentifier.fromString("someShortname").fold(e => throw e.head, identity)) +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/TapirToPekkoInterpreter.scala b/webapi/src/main/scala/org/knora/webapi/routing/TapirToPekkoInterpreter.scala new file mode 100644 index 0000000000..f3b5627618 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/TapirToPekkoInterpreter.scala @@ -0,0 +1,42 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing + +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.json.DeriveJsonCodec +import zio.json.JsonCodec + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +final case class TapirToPekkoInterpreter()(implicit executionContext: ExecutionContext) { + 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()) + .options + + private val interpreter: PekkoHttpServerInterpreter = PekkoHttpServerInterpreter(serverOptions) + + def toRoute(endpoint: ServerEndpoint[PekkoStreams with WebSockets, Future]): Route = interpreter.toRoute(endpoint) +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpoints.scala new file mode 100644 index 0000000000..9332a5166a --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpoints.scala @@ -0,0 +1,93 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing.admin + +import sttp.tapir._ +import sttp.tapir.generic.auto._ +import sttp.tapir.json.spray.{jsonBody => sprayJsonBody} +import zio.ZLayer + +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectGetResponseADM +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectKeywordsGetResponseADM +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectRestrictedViewSettingsGetResponseADM +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsGetResponseADM +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsKeywordsGetResponseADM +import org.knora.webapi.routing.BaseEndpoints +import org.knora.webapi.routing.PathVariables.projectIri +import org.knora.webapi.routing.PathVariables.projectShortcode +import org.knora.webapi.routing.PathVariables.projectShortname + +final case class ProjectsEndpoints( + baseEndpoints: BaseEndpoints +) extends ProjectsADMJsonProtocol { + + private val projectsBase = "admin" / "projects" + private val projectsByIri = projectsBase / "iri" / projectIri + private val projectsByShortcode = projectsBase / "shortcode" / projectShortcode + private val projectsByShortname = projectsBase / "shortname" / projectShortname + private val keywords = "Keywords" + private val restrictedViewSettings = "RestrictedViewSettings" + private val tags = List("Projects", "Admin API") + + val getAdminProjects = baseEndpoints.publicEndpoint.get + .in(projectsBase) + .out(sprayJsonBody[ProjectsGetResponseADM]) + .description("Returns all projects.") + .tags(tags) + + val getAdminProjectsKeywords = baseEndpoints.publicEndpoint.get + .in(projectsBase / keywords) + .out(sprayJsonBody[ProjectsKeywordsGetResponseADM]) + .description("Returns all unique keywords for all projects as a list.") + .tags(tags) + + val getAdminProjectsByProjectIri = baseEndpoints.publicEndpoint.get + .in(projectsByIri) + .out(sprayJsonBody[ProjectGetResponseADM]) + .description("Returns a single project identified through the IRI.") + .tags(tags) + + val getAdminProjectsByProjectShortcode = baseEndpoints.publicEndpoint.get + .in(projectsByShortcode) + .out(sprayJsonBody[ProjectGetResponseADM]) + .description("Returns a single project identified through the shortcode.") + .tags(tags) + + val getAdminProjectsByProjectShortname = baseEndpoints.publicEndpoint.get + .in(projectsByShortname) + .out(sprayJsonBody[ProjectGetResponseADM]) + .description("Returns a single project identified through the shortname.") + .tags(tags) + + val getAdminProjectsKeywordsByProjectIri = baseEndpoints.publicEndpoint.get + .in(projectsByIri / keywords) + .out(sprayJsonBody[ProjectKeywordsGetResponseADM]) + .description("Returns all keywords for a single project.") + .tags(tags) + + val getAdminProjectsByProjectIriRestrictedViewSettings = baseEndpoints.publicEndpoint.get + .in(projectsByIri / restrictedViewSettings) + .out(sprayJsonBody[ProjectRestrictedViewSettingsGetResponseADM]) + .description("Returns the project's restricted view settings identified through the IRI.") + .tags(tags) + + val getAdminProjectsByProjectShortcodeRestrictedViewSettings = baseEndpoints.publicEndpoint.get + .in(projectsByShortcode / restrictedViewSettings) + .out(sprayJsonBody[ProjectRestrictedViewSettingsGetResponseADM]) + .description("Returns the project's restricted view settings identified through the shortcode.") + .tags(tags) + + val getAdminProjectsByProjectShortnameRestrictedViewSettings = baseEndpoints.publicEndpoint.get + .in(projectsByShortname / restrictedViewSettings) + .out(sprayJsonBody[ProjectRestrictedViewSettingsGetResponseADM]) + .description("Returns the project's restricted view settings identified through the shortname.") + .tags(tags) +} + +object ProjectsEndpoints { + val layer = ZLayer.derive[ProjectsEndpoints] +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpointsHandlerF.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpointsHandlerF.scala new file mode 100644 index 0000000000..3bd0a5a155 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsEndpointsHandlerF.scala @@ -0,0 +1,90 @@ +/* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.routing.admin + +import zio.ZLayer + +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortcodeIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortnameIdentifier +import org.knora.webapi.routing.EndpointAndZioHandler +import org.knora.webapi.routing.HandlerMapperF +import org.knora.webapi.slice.admin.api.service.ProjectADMRestService + +final case class ProjectsEndpointsHandlerF( + projectsEndpoints: ProjectsEndpoints, + restService: ProjectADMRestService, + mapper: HandlerMapperF +) { + + val getAdminProjectsHandler = + EndpointAndZioHandler(projectsEndpoints.getAdminProjects, (_: Unit) => restService.getProjectsADMRequest()) + + val getAdminProjectsKeywordsHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsKeywords, + (_: Unit) => restService.getKeywords() + ) + + val getAdminProjectsByProjectIriHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectIri, + (id: IriIdentifier) => restService.getSingleProjectADMRequest(id) + ) + + val getAdminProjectsByProjectShortcodeHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectShortcode, + (id: ShortcodeIdentifier) => restService.getSingleProjectADMRequest(id) + ) + + val getAdminProjectsByProjectShortnameHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectShortname, + (id: ShortnameIdentifier) => restService.getSingleProjectADMRequest(id) + ) + + val getAdminProjectsKeywordsByProjectIriHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsKeywordsByProjectIri, + (iri: IriIdentifier) => restService.getKeywordsByProjectIri(iri.value) + ) + + val getAdminProjectByProjectIriRestrictedViewSettingsHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectIriRestrictedViewSettings, + (id: IriIdentifier) => restService.getProjectRestrictedViewSettings(id) + ) + + val getAdminProjectByProjectShortcodeRestrictedViewSettingsHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectShortcodeRestrictedViewSettings, + (id: ShortcodeIdentifier) => restService.getProjectRestrictedViewSettings(id) + ) + + val getAdminProjectByProjectShortnameRestrictedViewSettingsHandler = + EndpointAndZioHandler( + projectsEndpoints.getAdminProjectsByProjectShortnameRestrictedViewSettings, + (id: ShortnameIdentifier) => restService.getProjectRestrictedViewSettings(id) + ) + + val handlers = + List( + getAdminProjectsHandler, + getAdminProjectsKeywordsHandler, + getAdminProjectsByProjectIriHandler, + getAdminProjectsByProjectShortcodeHandler, + getAdminProjectsByProjectShortnameHandler, + getAdminProjectsKeywordsByProjectIriHandler, + getAdminProjectByProjectIriRestrictedViewSettingsHandler, + getAdminProjectByProjectShortcodeRestrictedViewSettingsHandler, + getAdminProjectByProjectShortnameRestrictedViewSettingsHandler + ).map(mapper.mapEndpointAndHandler(_)) +} + +object ProjectsEndpointsHandlerF { + val layer = ZLayer.derive[ProjectsEndpointsHandlerF] +} diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala index c8350049bf..0f9bb48c05 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/ProjectsRouteADM.scala @@ -5,7 +5,17 @@ package org.knora.webapi.routing.admin -import org.apache.pekko +import org.apache.pekko.Done +import org.apache.pekko.http.scaladsl.model.ContentTypes +import org.apache.pekko.http.scaladsl.model.HttpEntity +import org.apache.pekko.http.scaladsl.model.headers.ContentDispositionTypes +import org.apache.pekko.http.scaladsl.model.headers.`Content-Disposition` +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.PathMatcher +import org.apache.pekko.http.scaladsl.server.RequestContext +import org.apache.pekko.http.scaladsl.server.Route +import org.apache.pekko.stream.IOResult +import org.apache.pekko.stream.scaladsl.FileIO import zio._ import java.nio.file.Files @@ -27,35 +37,23 @@ import org.knora.webapi.routing.RouteUtilADM._ import org.knora.webapi.routing._ import org.knora.webapi.slice.admin.api.service.ProjectADMRestService -import pekko.Done -import pekko.http.scaladsl.model.ContentTypes -import pekko.http.scaladsl.model.HttpEntity -import pekko.http.scaladsl.model.headers.ContentDispositionTypes -import pekko.http.scaladsl.model.headers.`Content-Disposition` -import pekko.http.scaladsl.server.Directives._ -import pekko.http.scaladsl.server.PathMatcher -import pekko.http.scaladsl.server.RequestContext -import pekko.http.scaladsl.server.Route -import pekko.stream.IOResult -import pekko.stream.scaladsl.FileIO - -final case class ProjectsRouteADM()( +final case class ProjectsRouteADM( + interpreter: TapirToPekkoInterpreter, + projectsEndpointsHandlerF: ProjectsEndpointsHandlerF +)( private implicit val runtime: Runtime[ org.knora.webapi.routing.Authenticator with StringFormatter with MessageRelay with ProjectADMRestService ], private implicit val executionContext: ExecutionContext ) extends ProjectsADMJsonProtocol { + private val tapirRoutes: Route = projectsEndpointsHandlerF.handlers.map(interpreter.toRoute(_)).reduce(_ ~ _) + private val projectsBasePath: PathMatcher[Unit] = PathMatcher("admin" / "projects") def makeRoute: Route = - getProjects() ~ + tapirRoutes ~ addProject() ~ - getKeywords() ~ - getProjectKeywords() ~ - getProjectByIri() ~ - getProjectByShortname() ~ - getProjectByShortcode() ~ changeProject() ~ deleteProject() ~ getProjectMembersByIri() ~ @@ -64,19 +62,9 @@ final case class ProjectsRouteADM()( getProjectAdminMembersByIri() ~ getProjectAdminMembersByShortname() ~ getProjectAdminMembersByShortcode() ~ - getProjectRestrictedViewSettingsByIri() ~ - getProjectRestrictedViewSettingsByShortname() ~ - getProjectRestrictedViewSettingsByShortcode() ~ getProjectData() ~ postExportProject - /** - * Returns all projects. - */ - private def getProjects(): Route = path(projectsBasePath) { - get(runJsonRoute(ProjectsGetRequestADM(), _)) - } - /** * Creates a new project. */ @@ -93,57 +81,6 @@ final case class ProjectsRouteADM()( } } - /** - * Returns all unique keywords for all projects as a list. - */ - private def getKeywords(): Route = path(projectsBasePath / "Keywords") { - get(runJsonRoute(ProjectsKeywordsGetRequestADM(), _)) - } - - /** - * Returns all keywords for a single project. - */ - private def getProjectKeywords(): Route = - path(projectsBasePath / "iri" / Segment / "Keywords") { projectIri => - get { requestContext => - val requestTask = - ProjectIri - .make(projectIri) - .toZIO - .mapBoth(_ => BadRequestException(s"Invalid project IRI $projectIri"), ProjectKeywordsGetRequestADM) - runJsonRouteZ(requestTask, requestContext) - } - } - - /** - * Returns a single project identified through the IRI. - */ - private def getProjectByIri(): Route = - path(projectsBasePath / "iri" / Segment) { value => - get(getProject(IriIdentifier.fromString(value).toZIO, _)) - } - - private def getProject(idTask: Task[ProjectIdentifierADM], requestContext: RequestContext) = { - val requestTask = idTask.mapBoth(e => BadRequestException(e.getMessage), id => ProjectGetRequestADM(id)) - runJsonRouteZ(requestTask, requestContext) - } - - /** - * Returns a single project identified through the shortname. - */ - private def getProjectByShortname(): Route = - path(projectsBasePath / "shortname" / Segment) { value => - get(getProject(ShortnameIdentifier.fromString(value).toZIO, _)) - } - - /** - * Returns a single project identified through the shortcode. - */ - private def getProjectByShortcode(): Route = - path(projectsBasePath / "shortcode" / Segment) { value => - get(getProject(ShortcodeIdentifier.fromString(value).toZIO, _)) - } - /** * Updates a project identified by the IRI. */ @@ -249,38 +186,6 @@ final case class ProjectsRouteADM()( get(getProjectAdminMembers(ShortcodeIdentifier.fromString(value).toZIO, _)) } - /** - * Returns the project's restricted view settings identified through the IRI. - */ - private def getProjectRestrictedViewSettingsByIri(): Route = - path(projectsBasePath / "iri" / Segment / "RestrictedViewSettings") { value: String => - get(getProjectRestrictedViewSettings(IriIdentifier.fromString(value).toZIO, _)) - } - - private def getProjectRestrictedViewSettings(idTask: Task[ProjectIdentifierADM], requestContext: RequestContext) = { - val requestTask = for { - id <- idTask.mapError(e => BadRequestException(e.getMessage)) - request = ProjectRestrictedViewSettingsGetRequestADM(id) - } yield request - runJsonRouteZ(requestTask, requestContext) - } - - /** - * Returns the project's restricted view settings identified through the shortname. - */ - private def getProjectRestrictedViewSettingsByShortname(): Route = - path(projectsBasePath / "shortname" / Segment / "RestrictedViewSettings") { value: String => - get(getProjectRestrictedViewSettings(ShortnameIdentifier.fromString(value).toZIO, _)) - } - - /** - * Returns the project's restricted view settings identified through shortcode. - */ - private def getProjectRestrictedViewSettingsByShortcode(): Route = - path(projectsBasePath / "shortcode" / Segment / "RestrictedViewSettings") { value: String => - get(getProjectRestrictedViewSettings(ShortcodeIdentifier.fromString(value).toZIO, _)) - } - private val projectDataHeader = `Content-Disposition`(ContentDispositionTypes.attachment, Map(("filename", "project-data.trig")))