-
Notifications
You must be signed in to change notification settings - Fork 74
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add endpoint to resolve by id (#4261)
- Loading branch information
1 parent
9f6f178
commit 9f12e8b
Showing
16 changed files
with
637 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
137 changes: 137 additions & 0 deletions
137
...rch/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/IdResolution.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
|
||
} |
50 changes: 50 additions & 0 deletions
50
...scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/routes/IdResolutionRoutes.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
|
||
} |
105 changes: 105 additions & 0 deletions
105
...rc/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/elasticsearch/IdResolutionSuite.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.