diff --git a/integration/src/test/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala b/integration/src/test/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala index 3b27749fe87..9c24de91376 100644 --- a/integration/src/test/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/e2e/v2/SearchRouteV2R2RSpec.scala @@ -34,7 +34,6 @@ import org.knora.webapi.messages.util.rdf.JsonLDUtil import org.knora.webapi.messages.util.search.SparqlQueryConstants import org.knora.webapi.routing.UnsafeZioRun import org.knora.webapi.routing.v2.ResourcesRouteV2 -import org.knora.webapi.routing.v2.SearchRouteV2 import org.knora.webapi.routing.v2.StandoffRouteV2 import org.knora.webapi.routing.v2.ValuesRouteV2 import org.knora.webapi.sharedtestdata.SharedTestDataADM @@ -54,13 +53,9 @@ import pekko.http.scaladsl.model.headers.BasicHttpCredentials */ class SearchRouteV2R2RSpec extends R2RSpec { private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance - private val searchPathNew = UnsafeZioRun + private val searchPath = UnsafeZioRun .runOrThrow(ZIO.serviceWith[SearchApiRoutes](_.routes)) .reduce(_ ~ _) - private val searchPathOld = DSPApiDirectives.handleErrors(appConfig)( - SearchRouteV2(routeData.appConfig.v2.fulltextSearch.searchValueMinLength).makeRoute - ) - private val searchPath = searchPathNew ~ searchPathOld private val resourcePath = DSPApiDirectives.handleErrors(appConfig)(ResourcesRouteV2(appConfig).makeRoute) diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala index b6e55419ac1..5f1960cb6a6 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/SearchResponderV2.scala @@ -251,6 +251,10 @@ final case class SearchResponderV2Live( limitToStandoffClass: Option[SmartIri] ): Task[ResourceCountV2] = for { + _ <- ensureIsFulltextSearch(searchValue) + searchValue <- validateSearchString(searchValue) + limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri) + limitToStandoffClass <- ZIO.foreach(limitToStandoffClass)(ensureStandoffClass) countSparql <- ZIO.attempt( sparql.v2.txt .searchFulltext( @@ -300,6 +304,10 @@ final case class SearchResponderV2Live( ): Task[ReadResourcesSequenceV2] = { import org.knora.webapi.messages.util.search.FullTextMainQueryGenerator.FullTextSearchConstants for { + _ <- ensureIsFulltextSearch(searchValue) + searchValue <- validateSearchString(searchValue) + limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri) + limitToStandoffClass <- ZIO.foreach(limitToStandoffClass)(ensureStandoffClass) searchSparql <- ZIO.attempt( sparql.v2.txt @@ -856,7 +864,7 @@ final case class SearchResponderV2Live( ): Task[ResourceCountV2] = for { searchValue <- validateSearchString(searchValue) - _ <- ensureIsNotFullTextSearch(searchValue) + _ <- ensureIsFulltextSearch(searchValue) limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri) searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence countSparql = @@ -882,7 +890,14 @@ final case class SearchResponderV2Live( } else { ZIO.fail(BadRequestException(errMsg)) } } - private def ensureIsNotFullTextSearch(searchStr: String) = + private def ensureStandoffClass(standoffClassIri: SmartIri): Task[SmartIri] = { + val errMsg = s"Invalid standoff class IRI: $standoffClassIri" + if (standoffClassIri.isApiV2ComplexSchema) { + iriConverter.asInternalSmartIri(standoffClassIri).orElseFail(BadRequestException(errMsg)) + } else { ZIO.fail(BadRequestException(errMsg)) } + } + + private def ensureIsFulltextSearch(searchStr: String) = ZIO .fail(BadRequestException("It looks like you are submitting a Gravsearch request to a full-text search route")) .when(searchStr.contains(OntologyConstants.KnoraApi.ApiOntologyHostname)) @@ -911,7 +926,7 @@ final case class SearchResponderV2Live( val searchOffset = offset * appConfig.v2.resourcesSequence.resultsPerPage for { searchValue <- validateSearchString(searchValue) - _ <- ensureIsNotFullTextSearch(searchValue) + _ <- ensureIsFulltextSearch(searchValue) limitToResourceClass <- ZIO.foreach(limitToResourceClass)(ensureResourceClassIri) searchTerm = MatchStringWhileTyping(searchValue).generateLiteralForLuceneIndexWithoutExactSequence searchResourceByLabelSparql = SearchQueries.constructSearchByLabel( 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 da342769294..c4835b3a3b1 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -109,7 +109,6 @@ private final case class ApiRoutesImpl( PermissionsRouteADM(routeData, runtime).makeRoute ~ RejectingRoute(appConfig, runtime).makeRoute ~ ResourcesRouteV2(appConfig).makeRoute ~ - SearchRouteV2(appConfig.v2.fulltextSearch.searchValueMinLength).makeRoute ~ StandoffRouteV2().makeRoute ~ StoreRouteADM(routeData, runtime).makeRoute ~ UsersRouteADM().makeRoute ~ diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/SearchRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/SearchRouteV2.scala deleted file mode 100644 index 62a7202707c..00000000000 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/SearchRouteV2.scala +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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.v2 - -import org.apache.pekko.http.scaladsl.server.Directives.* -import org.apache.pekko.http.scaladsl.server.Route -import zio.* - -import dsp.errors.BadRequestException -import dsp.valueobjects.Iri -import org.knora.webapi.* -import org.knora.webapi.config.AppConfig -import org.knora.webapi.core.MessageRelay -import org.knora.webapi.messages.OntologyConstants -import org.knora.webapi.messages.SmartIri -import org.knora.webapi.messages.ValuesValidator -import org.knora.webapi.responders.v2.SearchResponderV2 -import org.knora.webapi.routing.Authenticator -import org.knora.webapi.routing.RouteUtilV2 -import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri -import org.knora.webapi.slice.resourceinfo.domain.IriConverter - -/** - * Provides a function for API routes that deal with search. - */ -final case class SearchRouteV2(searchValueMinLength: Int)( - private implicit val runtime: Runtime[AppConfig & Authenticator & IriConverter & SearchResponderV2 & MessageRelay] -) { - - private val LIMIT_TO_PROJECT = "limitToProject" - private val LIMIT_TO_RESOURCE_CLASS = "limitToResourceClass" - private val OFFSET = "offset" - private val LIMIT_TO_STANDOFF_CLASS = "limitToStandoffClass" - private val RETURN_FILES = "returnFiles" - - def makeRoute: Route = fullTextSearchCount() ~ fullTextSearch() - - /** - * Gets the requested offset. Returns zero if no offset is indicated. - * - * @param params the GET parameters. - * @return the offset to be used for paging. - */ - private def getOffsetFromParams(params: Map[String, String]): IO[BadRequestException, Int] = - params - .get(OFFSET) - .map { offsetStr => - ZIO - .fromOption(ValuesValidator.validateInt(offsetStr)) - .orElseFail(BadRequestException(s"offset is expected to be an Integer, but $offsetStr given")) - .filterOrFail(_ >= 0)( - BadRequestException(s"offset is expected to be a positive Integer, but $offsetStr given") - ) - } - .getOrElse(ZIO.succeed(0)) - - private def getProjectIri(params: Map[String, String]): IO[BadRequestException, Option[ProjectIri]] = - ZIO - .fromOption(params.get(LIMIT_TO_PROJECT)) - .flatMap(iri => ProjectIri.from(iri).toZIO.orElseFail(Some(BadRequestException(s"$iri is not a valid Iri")))) - .unsome - - /** - * Gets the resource class the search should be restricted to, if any. - * - * @param params the GET parameters. - * @return the internal resource class, if any. - */ - private def getResourceClassFromParams( - params: Map[String, String] - ): ZIO[IriConverter, BadRequestException, Option[SmartIri]] = - params - .get(LIMIT_TO_RESOURCE_CLASS) - .map { resourceClassIriStr => - IriConverter - .asSmartIri(resourceClassIriStr) - .orElseFail(BadRequestException(s"Invalid resource class IRI: $resourceClassIriStr")) - .filterOrFail(_.isKnoraApiV2EntityIri)( - BadRequestException(s"$resourceClassIriStr is not a valid knora-api resource class IRI") - ) - .map(asSomeIriWithInternalSchema) - } - .getOrElse(ZIO.none) - - private def asSomeIriWithInternalSchema(iri: SmartIri): Some[SmartIri] = Some(iri.toOntologySchema(InternalSchema)) - - /** - * Gets the standoff class the search should be restricted to. - * - * @param params the GET parameters. - * @return the internal standoff class, if any. - */ - private def getStandoffClass(params: Map[String, String]): ZIO[IriConverter, BadRequestException, Option[SmartIri]] = - params - .get(LIMIT_TO_STANDOFF_CLASS) - .map { standoffClassIriStr => - IriConverter - .asSmartIri(standoffClassIriStr) - .orElseFail(BadRequestException(s"Invalid standoff class IRI: $standoffClassIriStr")) - .filterOrFail(_.isApiV2ComplexSchema)( - BadRequestException(s"$standoffClassIriStr is not a valid knora-api standoff class IRI") - ) - .map(asSomeIriWithInternalSchema) - } - .getOrElse(ZIO.none) - - private def fullTextSearchCount(): Route = - path("v2" / "search" / "count" / Segment) { - searchStr => // TODO: if a space is encoded as a "+", this is not converted back to a space - get { requestContext => - val params: Map[String, String] = requestContext.request.uri.query().toMap - val response = for { - _ <- ensureIsNotFullTextSearch(searchStr) - escapedSearchStr <- validateSearchString(searchStr) - limitToProject <- getProjectIri(params) - limitToResourceClass <- getResourceClassFromParams(params) - limitToStandoffClass <- getStandoffClass(params) - response <- SearchResponderV2.fulltextSearchCountV2( - escapedSearchStr, - limitToProject, - limitToResourceClass, - limitToStandoffClass - ) - } yield response - RouteUtilV2.completeResponse(response, requestContext) - } - } - - private def validateSearchString(searchStr: String) = - ZIO - .fromOption(Iri.toSparqlEncodedString(searchStr)) - .orElseFail(throw BadRequestException(s"Invalid search string: '$searchStr'")) - .filterOrElseWith(_.length >= searchValueMinLength) { it => - val errorMsg = - s"A search value is expected to have at least length of $searchValueMinLength, but '$it' given of length ${it.length}." - ZIO.fail(BadRequestException(errorMsg)) - } - - private def ensureIsNotFullTextSearch(searchStr: String) = - ZIO - .fail( - BadRequestException( - "It looks like you are submitting a Gravsearch request to a full-text search route" - ) - ) - .when(searchStr.contains(OntologyConstants.KnoraApi.ApiOntologyHostname)) - - private def fullTextSearch(): Route = path("v2" / "search" / Segment) { - searchStr => // TODO: if a space is encoded as a "+", this is not converted back to a space - get { requestContext => - val targetSchemaTask = RouteUtilV2.getOntologySchema(requestContext) - val schemaOptionsTask = RouteUtilV2.getSchemaOptions(requestContext) - - val params: Map[String, String] = requestContext.request.uri.query().toMap - val requestTask = for { - _ <- ensureIsNotFullTextSearch(searchStr) - escapedSearchStr <- validateSearchString(searchStr) - offset <- getOffsetFromParams(params) - limitToProject <- getProjectIri(params) - limitToResourceClass <- getResourceClassFromParams(params) - limitToStandoffClass <- getStandoffClass(params) - returnFiles = ValuesValidator.optionStringToBoolean(params.get(RETURN_FILES), fallback = false) - requestingUser <- Authenticator.getUserADM(requestContext) - schemaAndOptions <- targetSchemaTask.zip(schemaOptionsTask).map { case (s, o) => SchemaRendering(s, o) } - response <- SearchResponderV2.fulltextSearchV2( - escapedSearchStr, - offset, - limitToProject, - limitToResourceClass, - limitToStandoffClass, - returnFiles, - schemaAndOptions, - requestingUser - ) - } yield response - RouteUtilV2.completeResponse(requestTask, requestContext, targetSchemaTask, schemaOptionsTask.map(Some(_))) - } - } -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/KnoraProject.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/KnoraProject.scala index 6a205b447ea..c0f3e620239 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/KnoraProject.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/KnoraProject.scala @@ -7,7 +7,6 @@ package org.knora.webapi.slice.admin.domain.model import sttp.tapir.Codec import sttp.tapir.CodecFormat -import sttp.tapir.DecodeResult import sttp.tapir.Schema import zio.NonEmptyChunk import zio.json.* @@ -15,7 +14,6 @@ import zio.prelude.Validation import scala.util.matching.Regex -import dsp.errors.BadRequestException import dsp.errors.ValidationException import dsp.valueobjects.Iri import dsp.valueobjects.Iri.isProjectIri @@ -47,11 +45,8 @@ object KnoraProject { implicit val codec: JsonCodec[ProjectIri] = JsonCodec[String].transformOrFail(ProjectIri.from(_).toEitherWith(e => e.head.getMessage), _.value) - implicit val tapirCodec: Codec[String, ProjectIri, CodecFormat.TextPlain] = Codec.string.mapDecode(str => - ProjectIri - .from(str) - .fold(f => DecodeResult.Error(f.head.getMessage, BadRequestException(f.head.getMessage)), DecodeResult.Value(_)) - )(_.value) + implicit val tapirCodec: Codec[String, ProjectIri, CodecFormat.TextPlain] = + Codec.string.mapEither(str => ProjectIri.from(str).toEitherWith(e => e.head.getMessage))(_.value) def unsafeFrom(str: String): ProjectIri = from(str).fold(e => throw e.head, identity) 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 d7e65903338..928d18035a1 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 @@ -65,6 +65,9 @@ object SearchEndpointsInputs { val limitToStandoffClass: EndpointInput.Query[Option[SimpleIri]] = query[Option[SimpleIri]]("limitToStandoffClass").description("The standoff class to limit the search to.") + + val returnFiles: EndpointInput.Query[Boolean] = + query[Boolean]("returnFiles").description("Whether to return files in the search results.").default(false) } final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { @@ -129,6 +132,30 @@ final case class SearchEndpoints(baseEndpoints: BaseEndpoints) { .out(header[MediaType](HeaderNames.ContentType)) .tags(tags) .description("Search for resources by label.") + + val getFullTextSearch = baseEndpoints.withUserEndpoint.get + .in("v2" / "search" / path[String]("searchTerm")) + .in(ApiV2.Inputs.formatOptions) + .in(SearchEndpointsInputs.offset) + .in(SearchEndpointsInputs.limitToProject) + .in(SearchEndpointsInputs.limitToResourceClass) + .in(SearchEndpointsInputs.limitToStandoffClass) + .in(SearchEndpointsInputs.returnFiles) + .out(stringBody) + .out(header[MediaType](HeaderNames.ContentType)) + .tags(tags) + .description("Search for resources by label.") + + val getFullTextSearchCount = baseEndpoints.withUserEndpoint.get + .in("v2" / "search" / "count" / path[String]("searchTerm")) + .in(ApiV2.Inputs.formatOptions) + .in(SearchEndpointsInputs.limitToProject) + .in(SearchEndpointsInputs.limitToResourceClass) + .in(SearchEndpointsInputs.limitToStandoffClass) + .out(stringBody) + .out(header[MediaType](HeaderNames.ContentType)) + .tags(tags) + .description("Search for resources by label.") } object SearchEndpoints { @@ -190,8 +217,39 @@ final case class SearchApiRoutes( } ) + private val getFullTextSearch = + SecuredEndpointAndZioHandler[ + (String, FormatOptions, Offset, Option[ProjectIri], Option[SimpleIri], Option[SimpleIri], Boolean), + (RenderedResponse, MediaType) + ]( + searchEndpoints.getFullTextSearch, + user => { case (query, opts, offset, project, resourceClass, standoffClass, returnFiles) => + searchRestService.fullTextSearch(query, opts, offset, project, resourceClass, standoffClass, returnFiles, user) + } + ) + + private val getFullTextSearchCount = + SecuredEndpointAndZioHandler[ + (String, FormatOptions, Option[ProjectIri], Option[SimpleIri], Option[SimpleIri]), + (RenderedResponse, MediaType) + ]( + searchEndpoints.getFullTextSearchCount, + _ => { case (query, opts, project, resourceClass, standoffClass) => + searchRestService.fullTextSearchCount(query, opts, project, resourceClass, standoffClass) + } + ) + val routes: Seq[Route] = - Seq(getSearchByLabel, getSearchByLabelCount, postGravsearch, getGravsearch, postGravsearchCount, getGravsearchCount) + Seq( + getFullTextSearch, + getFullTextSearchCount, + getSearchByLabel, + getSearchByLabelCount, + postGravsearch, + getGravsearch, + postGravsearchCount, + getGravsearchCount + ) .map(it => mapper.mapEndpointAndHandler(it)) .map(it => tapirToPekko.toRoute(it)) } @@ -241,7 +299,48 @@ final case class SearchRestService( searchResult <- searchResponderV2.gravsearchCountV2(query, user) response <- renderer.render(searchResult, opts) } yield response + + def fullTextSearch( + query: RenderedResponse, + opts: FormatOptions, + offset: Offset, + project: Option[ProjectIri], + resourceClass: Option[SimpleIri], + standoffClass: Option[SimpleIri], + returnFiles: Boolean, + user: UserADM + ): Task[(RenderedResponse, MediaType)] = for { + resourceClass <- ZIO.foreach(resourceClass.map(_.value))(iriConverter.asSmartIri) + standoffClass <- ZIO.foreach(standoffClass.map(_.value))(iriConverter.asSmartIri) + searchResult <- searchResponderV2.fulltextSearchV2( + query, + offset.value, + project, + resourceClass, + standoffClass, + returnFiles, + opts.schemaRendering, + user + ) + response <- renderer.render(searchResult, opts) + } yield response + + def fullTextSearchCount( + query: RenderedResponse, + opts: FormatOptions, + project: Option[ProjectIri], + resourceClass: Option[SimpleIri], + standoffClass: Option[SimpleIri] + ): zio.Task[ + (_root_.org.knora.webapi.slice.common.api.KnoraResponseRenderer.RenderedResponse, _root_.sttp.model.MediaType) + ] = for { + resourceClass <- ZIO.foreach(resourceClass.map(_.value))(iriConverter.asSmartIri) + standoffClass <- ZIO.foreach(standoffClass.map(_.value))(iriConverter.asSmartIri) + searchResult <- searchResponderV2.fulltextSearchCountV2(query, project, resourceClass, standoffClass) + response <- renderer.render(searchResult, opts) + } yield response } + object SearchRestService { val layer = ZLayer.derive[SearchRestService] }