Skip to content

Commit

Permalink
Add endpoint to resolve by id (#4261)
Browse files Browse the repository at this point in the history
  • Loading branch information
olivergrabinski authored Sep 25, 2023
1 parent 9f6f178 commit 9f12e8b
Show file tree
Hide file tree
Showing 16 changed files with 637 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.indexing.ElasticSearc
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ElasticSearchViewRejection.ProjectContextRejection
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.{contexts, defaultElasticsearchMapping, defaultElasticsearchSettings, schema => viewsSchemaId, ElasticSearchView, ElasticSearchViewEvent}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.{DefaultViewsQuery, ElasticSearchQueryError}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes.{ElasticSearchIndexingRoutes, ElasticSearchQueryRoutes, ElasticSearchViewsRoutes, ElasticSearchViewsRoutesHandler}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes._
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.JsonLdApi
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue.ContextObject
Expand Down Expand Up @@ -259,6 +259,28 @@ class ElasticSearchPluginModule(priority: Int) extends ModuleDef {
)
}

make[IdResolution].from { (defaultViewsQuery: DefaultViewsQuery.Elasticsearch, shifts: ResourceShifts) =>
new IdResolution(defaultViewsQuery, (resourceRef, projectRef) => shifts.fetch(resourceRef, projectRef))
}

make[IdResolutionRoutes].from {
(
identities: Identities,
aclCheck: AclCheck,
idResolution: IdResolution,
s: Scheduler,
ordering: JsonKeyOrdering,
rcr: RemoteContextResolution @Id("aggregate"),
fusionConfig: FusionConfig
) =>
new IdResolutionRoutes(identities, aclCheck, idResolution)(
s,
ordering,
rcr,
fusionConfig
)
}

make[ElasticSearchScopeInitialization]
.from { (views: ElasticSearchViews, serviceAccount: ServiceAccount, config: ElasticSearchViewsConfig) =>
new ElasticSearchScopeInitialization(views, serviceAccount, config.defaults)
Expand Down Expand Up @@ -325,6 +347,7 @@ class ElasticSearchPluginModule(priority: Int) extends ModuleDef {
es: ElasticSearchViewsRoutes,
query: ElasticSearchQueryRoutes,
indexing: ElasticSearchIndexingRoutes,
idResolutionRoute: IdResolutionRoutes,
schemeDirectives: DeltaSchemeDirectives,
baseUri: BaseUri
) =>
Expand All @@ -334,7 +357,8 @@ class ElasticSearchPluginModule(priority: Int) extends ModuleDef {
schemeDirectives,
es.routes,
query.routes,
indexing.routes
indexing.routes,
idResolutionRoute.routes
)(baseUri),
requiresStrictEntity = true
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch

import ch.epfl.bluebrain.nexus.delta.kernel.search.Pagination.FromPagination
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionResponse._
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.ResourcesSearchParams
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.DefaultSearchRequest.RootSearch
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.ElasticSearchQueryError.AuthorizationFailed
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.{DefaultViewsQuery, ElasticSearchQueryError}
import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri
import ch.epfl.bluebrain.nexus.delta.rdf.RdfError
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdOptions}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.{CompactedJsonLd, ExpandedJsonLd}
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdContent
import ch.epfl.bluebrain.nexus.delta.sdk.model.search.SearchResults.searchResultsJsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.sdk.model.search.{SearchResults, SortList}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
import io.circe.JsonObject
import monix.bio.{IO, UIO}

import java.rmi.UnexpectedException

/**
* @param defaultViewsQuery
* how to list resources from the default elasticsearch views
* @param fetchResource
* how to fetch a resource given a resourceRef and the project it lives in
*/
class IdResolution(
defaultViewsQuery: DefaultViewsQuery.Elasticsearch,
fetchResource: (ResourceRef, ProjectRef) => UIO[Option[JsonLdContent[_, _]]]
) {

/**
* Attempts to resolve the provided identifier across projects that the caller has access to
*
* - If the query response is empty, leads to AuthorizationFailed.
* - If the query returns a single result, attempts to fetch the resource
* - If there are multiple results, they are returned as [[SearchResults]]
*
* @param iri
* identifier of the resource to be resolved
* @param caller
* user having requested the resolution
*/
def resolve(
iri: Iri
)(implicit caller: Caller): IO[ElasticSearchQueryError, Result] = {
val locate = ResourcesSearchParams(id = Some(iri))
val request = RootSearch(locate, FromPagination(0, 10000), SortList.empty)

def fetchSingleResult: ProjectRef => UIO[Result] = { projectRef =>
val resourceRef = ResourceRef(iri)
fetchResource(resourceRef, projectRef)
.map {
_.map(SingleResult(resourceRef, projectRef, _))
}
.flatMap {
case Some(result) => IO.pure(result)
case None => IO.terminate(new UnexpectedException("Resource found in ES payload but could not be fetched."))
}
}

defaultViewsQuery
.list(request)
.flatMap { searchResults =>
searchResults.results match {
case Nil => IO.raiseError(AuthorizationFailed)
case Seq(result) => projectRefFromSource(result.source).flatMap(fetchSingleResult)
case _ => UIO.pure(MultipleResults(searchResults))
}
}
}

/** Extract the _project field of a given [[JsonObject]] as projectRef */
private def projectRefFromSource(source: JsonObject) =
source("_project")
.flatMap(_.as[Iri].toOption)
.flatMap(projectRefFromIri) match {
case Some(projectRef) => UIO.pure(projectRef)
case None => UIO.terminate(new UnexpectedException("Could not read '_project' field as IRI."))
}

private val projectRefRegex =
s"^.+/projects/(${Label.regex.regex})/(${Label.regex.regex})".r

private def projectRefFromIri(iri: Iri) =
iri.toString match {
case projectRefRegex(org, proj) =>
Some(ProjectRef(Label.unsafe(org), Label.unsafe(proj)))
case _ =>
None
}
}

object IdResolutionResponse {
sealed trait Result

final case class SingleResult[A](id: ResourceRef, project: ProjectRef, content: JsonLdContent[A, _]) extends Result

case class MultipleResults(searchResults: SearchResults[JsonObject]) extends Result

private val searchJsonLdEncoder: JsonLdEncoder[SearchResults[JsonObject]] =
searchResultsJsonLdEncoder(ContextValue(contexts.search))

implicit def resultJsonLdEncoder: JsonLdEncoder[Result] =
new JsonLdEncoder[Result] {

// helps with type inference
private def encoder[A](value: JsonLdContent[A, _]): JsonLdEncoder[A] = value.encoder

override def context(value: Result): ContextValue = value match {
case SingleResult(_, _, content) => encoder(content).context(content.resource.value)
case MultipleResults(searchResults) => searchJsonLdEncoder.context(searchResults)
}

override def expand(
value: Result
)(implicit opts: JsonLdOptions, api: JsonLdApi, rcr: RemoteContextResolution): IO[RdfError, ExpandedJsonLd] =
value match {
case SingleResult(_, _, content) => encoder(content).expand(content.resource.value)
case MultipleResults(searchResults) => searchJsonLdEncoder.expand(searchResults)
}

override def compact(
value: Result
)(implicit opts: JsonLdOptions, api: JsonLdApi, rcr: RemoteContextResolution): IO[RdfError, CompactedJsonLd] =
value match {
case SingleResult(_, _, content) => encoder(content).compact(content.resource.value)
case MultipleResults(searchResults) => searchJsonLdEncoder.compact(searchResults)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.routes

import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionResponse.{MultipleResults, SingleResult}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.ElasticSearchQueryError
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.{IdResolution, IdResolutionResponse}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._
import ch.epfl.bluebrain.nexus.delta.sdk.fusion.FusionConfig
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities
import monix.bio.{IO, UIO}
import monix.execution.Scheduler

class IdResolutionRoutes(
identities: Identities,
aclCheck: AclCheck,
idResolution: IdResolution
)(implicit s: Scheduler, jko: JsonKeyOrdering, rcr: RemoteContextResolution, fusionConfig: FusionConfig)
extends AuthDirectives(identities, aclCheck) {

def routes: Route =
pathPrefix("resolve") {
extractCaller { implicit caller =>
(get & iriSegment & pathEndOrSingleSlash) { iri =>
val resolved = idResolution.resolve(iri)

emitOrFusionRedirect(
fusionUri(resolved),
emit(resolved)
)
}
}
}

private def fusionUri(
resolved: IO[ElasticSearchQueryError, IdResolutionResponse.Result]
): UIO[Uri] =
resolved
.flatMap {
case SingleResult(id, project, _) => fusionResourceUri(project, id.iri)
case MultipleResults(_) => fusionLoginUri
}
.onErrorHandleWith { _ => fusionLoginUri }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch

import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionResponse.{MultipleResults, SingleResult}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.IdResolutionSuite.searchResults
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.{defaultViewId, permissions}
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.ElasticSearchQueryError.AuthorizationFailed
import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.query.{DefaultSearchRequest, DefaultViewsQuery}
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution
import ch.epfl.bluebrain.nexus.delta.rdf.syntax.iriStringContextSyntax
import ch.epfl.bluebrain.nexus.delta.sdk.DataResource
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck
import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress
import ch.epfl.bluebrain.nexus.delta.sdk.generators.ResourceGen
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.model.search.{AggregationResult, SearchResults}
import ch.epfl.bluebrain.nexus.delta.sdk.views.View.IndexingView
import ch.epfl.bluebrain.nexus.delta.sdk.views.ViewRef
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Group, User}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef, ResourceRef}
import ch.epfl.bluebrain.nexus.testkit.TestHelpers.jsonContentOf
import ch.epfl.bluebrain.nexus.testkit.bio.BioSuite
import io.circe.{Json, JsonObject}
import monix.bio.UIO

class IdResolutionSuite extends BioSuite with Fixtures {

private val realm = Label.unsafe("myrealm")
private val alice: Caller = Caller(User("Alice", realm), Set(User("Alice", realm), Group("users", realm)))

private val org = Label.unsafe("org")

private val project1 = ProjectRef(org, Label.unsafe("proj"))
private val defaultView = ViewRef(project1, defaultViewId)

private val org2 = Label.unsafe("org2")
private val project2 = ProjectRef(org2, Label.unsafe("proj2"))
private val defaultView2 = ViewRef(project2, defaultViewId)

private val aclCheck = AclSimpleCheck.unsafe(
(alice.subject, AclAddress.Root, Set(permissions.read)) // Alice has full access
)

private def fetchViews = UIO.pure {
val viewRefs = List(defaultView, defaultView2)
viewRefs.map { ref => IndexingView(ref, "index", permissions.read) }
}

private def defaultViewsQuery(searchResults: SearchResults[JsonObject]): DefaultViewsQuery.Elasticsearch =
DefaultViewsQuery(
_ => fetchViews,
aclCheck,
(_: DefaultSearchRequest, _: Set[IndexingView]) => UIO.pure(searchResults),
(_: DefaultSearchRequest, _: Set[IndexingView]) => UIO.pure(AggregationResult(0, JsonObject.empty))
)

private val iri = iri"https://bbp.epfl.ch/data/resource"

private val successId = nxv + "success"
private val successContent =
ResourceGen.jsonLdContent(successId, project1, jsonContentOf("resources/resource.json", "id" -> successId))

private def fetchResource =
(_: ResourceRef, _: ProjectRef) => UIO.some(successContent)

private val res = JsonObject(
"@id" -> Json.fromString(iri.toString),
"_project" -> Json.fromString(s"https://bbp.epfl.ch/nexus/v1/projects/$project1")
)

test("No listing results lead to AuthorizationFailed") {
val noListingResults = defaultViewsQuery(searchResults(Seq.empty))
new IdResolution(noListingResults, fetchResource)
.resolve(iri)(alice)
.assertError(_ == AuthorizationFailed)
}

test("Single listing result leads to the resource being fetched") {
val singleListingResult = defaultViewsQuery(searchResults(Seq(res)))
new IdResolution(singleListingResult, fetchResource)
.resolve(iri)(alice)
.assert(SingleResult(ResourceRef(iri), project1, successContent))
}

test("Multiple listing results lead to search results") {
val searchRes = searchResults(Seq(res, res))
val multipleQueryResults = defaultViewsQuery(searchRes)
new IdResolution(multipleQueryResults, fetchResource)
.resolve(iri)(alice)
.assert(MultipleResults(searchRes))
}

}

object IdResolutionSuite {
def asResourceF(resourceRef: ResourceRef, projectRef: ProjectRef)(implicit
rcr: RemoteContextResolution
): DataResource = {
val resource = ResourceGen.resource(resourceRef.iri, projectRef, Json.obj())
ResourceGen.resourceFor(resource)
}

private def searchResults(jsons: Seq[JsonObject]): SearchResults[JsonObject] =
SearchResults(jsons.size.toLong, jsons)
}
Loading

0 comments on commit 9f12e8b

Please sign in to comment.