diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutes.scala index 8b7b5414f4..0c5b93f3d6 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutes.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutes.scala @@ -27,7 +27,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resources.{NexusSource, Resources} import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.{InvalidJsonLdFormat, InvalidSchemaRejection, ResourceNotFound} import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.{Resource, ResourceRejection} import io.circe.{Json, Printer} -import kamon.instrumentation.akka.http.TracingDirectives.operationName import monix.bio.IO import monix.execution.Scheduler @@ -62,7 +61,6 @@ final class ResourcesRoutes( with CirceUnmarshalling with RdfMarshalling { - import baseUri.prefixSegment import schemeDirectives._ private val resourceSchema = schemas.resources @@ -79,13 +77,11 @@ final class ResourcesRoutes( // Create a resource without schema nor id segment (post & pathEndOrSingleSlash & noParameter("rev") & entity(as[NexusSource]) & indexingMode) { (source, mode) => - operationName(s"$prefixSegment/resources/{org}/{project}") { - authorizeFor(ref, Write).apply { - emit( - Created, - resources.create(ref, resourceSchema, source.value).tapEval(index(ref, _, mode)).map(_.void) - ) - } + authorizeFor(ref, Write).apply { + emit( + Created, + resources.create(ref, resourceSchema, source.value).tapEval(index(ref, _, mode)).map(_.void) + ) } }, (idSegment & indexingMode) { (schema, mode) => @@ -93,167 +89,163 @@ final class ResourcesRoutes( concat( // Create a resource with schema but without id segment (post & pathEndOrSingleSlash & noParameter("rev")) { - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}") { - authorizeFor(ref, Write).apply { - entity(as[NexusSource]) { source => - emit( - Created, - resources - .create(ref, schema, source.value) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) - ) - } + authorizeFor(ref, Write).apply { + entity(as[NexusSource]) { source => + emit( + Created, + resources + .create(ref, schema, source.value) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectWhen(wrongJsonOrNotFound) + ) } } }, idSegment { id => concat( pathEndOrSingleSlash { - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}/{id}") { - concat( - // Create or update a resource - put { - authorizeFor(ref, Write).apply { - (parameter("rev".as[Int].?) & pathEndOrSingleSlash & entity(as[NexusSource])) { - case (None, source) => - // Create a resource with schema and id segments - emit( - Created, - resources - .create(id, ref, schema, source.value) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) - ) - case (Some(rev), source) => - // Update a resource - emit( - resources - .update(id, ref, schemaOpt, rev, source.value) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) - ) - } - } - }, - // Deprecate a resource - (delete & parameter("rev".as[Int])) { rev => - authorizeFor(ref, Write).apply { - emit( - resources - .deprecate(id, ref, schemaOpt, rev) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) - ) - } - }, - // Fetch a resource - (get & idSegmentRef(id)) { id => - emitOrFusionRedirect( - ref, - id, - authorizeFor(ref, Read).apply { + concat( + // Create or update a resource + put { + authorizeFor(ref, Write).apply { + (parameter("rev".as[Int].?) & pathEndOrSingleSlash & entity(as[NexusSource])) { + case (None, source) => + // Create a resource with schema and id segments emit( + Created, resources - .fetch(id, ref, schemaOpt) - .leftWiden[ResourceRejection] + .create(id, ref, schema, source.value) + .tapEval(index(ref, _, mode)) + .map(_.void) .rejectWhen(wrongJsonOrNotFound) ) - } + case (Some(rev), source) => + // Update a resource + emit( + resources + .update(id, ref, schemaOpt, rev, source.value) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectWhen(wrongJsonOrNotFound) + ) + } + } + }, + // Deprecate a resource + (delete & parameter("rev".as[Int])) { rev => + authorizeFor(ref, Write).apply { + emit( + resources + .deprecate(id, ref, schemaOpt, rev) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectWhen(wrongJsonOrNotFound) ) } - ) - } - }, - (pathPrefix("refresh") & put & pathEndOrSingleSlash) { - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}/{id}/refresh") { - authorizeFor(ref, Write).apply { - emit( - OK, - resources - .refresh(id, ref, schemaOpt) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) + }, + // Fetch a resource + (get & idSegmentRef(id)) { id => + emitOrFusionRedirect( + ref, + id, + authorizeFor(ref, Read).apply { + emit( + resources + .fetch(id, ref, schemaOpt) + .leftWiden[ResourceRejection] + .rejectWhen(wrongJsonOrNotFound) + ) + } ) } + ) + }, + (pathPrefix("refresh") & put & pathEndOrSingleSlash) { + authorizeFor(ref, Write).apply { + emit( + OK, + resources + .refresh(id, ref, schemaOpt) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectWhen(wrongJsonOrNotFound) + ) } }, (pathPrefix("validate") & get & pathEndOrSingleSlash & idSegmentRef(id)) { id => - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}/{id}/validate") { - authorizeFor(ref, Write).apply { - emit( - resources - .validate(id, ref, schemaOpt) - .leftWiden[ResourceRejection] - ) - } + authorizeFor(ref, Write).apply { + emit( + resources + .validate(id, ref, schemaOpt) + .leftWiden[ResourceRejection] + ) } + }, // Fetch a resource original source (pathPrefix("source") & get & pathEndOrSingleSlash & idSegmentRef(id)) { id => - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}/{id}/source") { - authorizeFor(ref, Read).apply { - parameter("annotate".as[Boolean].withDefault(false)) { annotate => - implicit val source: Printer = sourcePrinter - if (annotate) { - emit( - resources - .fetch(id, ref, schemaOpt) - .flatMap(asSourceWithMetadata) - ) - } else { - val sourceIO = resources.fetch(id, ref, schemaOpt).map(_.value.source) - val value = sourceIO.leftWiden[ResourceRejection] - emit(value.rejectWhen(wrongJsonOrNotFound)) - } + authorizeFor(ref, Read).apply { + parameter("annotate".as[Boolean].withDefault(false)) { annotate => + implicit val source: Printer = sourcePrinter + if (annotate) { + emit( + resources + .fetch(id, ref, schemaOpt) + .flatMap(asSourceWithMetadata) + ) + } else { + val sourceIO = resources.fetch(id, ref, schemaOpt).map(_.value.source) + val value = sourceIO.leftWiden[ResourceRejection] + emit(value.rejectWhen(wrongJsonOrNotFound)) } } } }, + // Get remote contexts + pathPrefix("remote-contexts") { + (get & idSegmentRef(id) & pathEndOrSingleSlash & authorizeFor(ref, Read)) { id => + val remoteContextsIO = resources.fetchState(id, ref, schemaOpt).map(_.remoteContexts) + emit(remoteContextsIO.leftWiden[ResourceRejection]) + } + }, // Tag a resource pathPrefix("tags") { - operationName(s"$prefixSegment/resources/{org}/{project}/{schema}/{id}/tags") { - concat( - // Fetch a resource tags - (get & idSegmentRef(id) & pathEndOrSingleSlash & authorizeFor(ref, Read)) { id => - val tagsIO = resources.fetch(id, ref, schemaOpt).map(_.value.tags) - emit(tagsIO.leftWiden[ResourceRejection].rejectWhen(wrongJsonOrNotFound)) - }, - // Tag a resource - (post & parameter("rev".as[Int]) & pathEndOrSingleSlash) { rev => - authorizeFor(ref, Write).apply { - entity(as[Tag]) { case Tag(tagRev, tag) => - emit( - Created, - resources - .tag(id, ref, schemaOpt, tag, tagRev, rev) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectWhen(wrongJsonOrNotFound) - ) - } + concat( + // Fetch a resource tags + (get & idSegmentRef(id) & pathEndOrSingleSlash & authorizeFor(ref, Read)) { id => + val tagsIO = resources.fetch(id, ref, schemaOpt).map(_.value.tags) + emit(tagsIO.leftWiden[ResourceRejection].rejectWhen(wrongJsonOrNotFound)) + }, + // Tag a resource + (post & parameter("rev".as[Int]) & pathEndOrSingleSlash) { rev => + authorizeFor(ref, Write).apply { + entity(as[Tag]) { case Tag(tagRev, tag) => + emit( + Created, + resources + .tag(id, ref, schemaOpt, tag, tagRev, rev) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectWhen(wrongJsonOrNotFound) + ) } - }, - // Delete a tag - (tagLabel & delete & parameter("rev".as[Int]) & pathEndOrSingleSlash & authorizeFor( - ref, - Write - )) { (tag, rev) => - emit( - resources - .deleteTag(id, ref, schemaOpt, tag, rev) - .tapEval(index(ref, _, mode)) - .map(_.void) - .rejectOn[ResourceNotFound] - ) } - ) - } + }, + // Delete a tag + (tagLabel & delete & parameter("rev".as[Int]) & pathEndOrSingleSlash & authorizeFor( + ref, + Write + )) { (tag, rev) => + emit( + resources + .deleteTag(id, ref, schemaOpt, tag, rev) + .tapEval(index(ref, _, mode)) + .map(_.void) + .rejectOn[ResourceNotFound] + ) + } + ) } ) } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala index 805e6b3585..c1988efd43 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/DeltaModule.scala @@ -77,22 +77,24 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class make[RemoteContextResolution].named("aggregate").fromEffect { (otherCtxResolutions: Set[RemoteContextResolution]) => for { - errorCtx <- ContextValue.fromFile("contexts/error.json") - metadataCtx <- ContextValue.fromFile("contexts/metadata.json") - searchCtx <- ContextValue.fromFile("contexts/search.json") - pipelineCtx <- ContextValue.fromFile("contexts/pipeline.json") - tagsCtx <- ContextValue.fromFile("contexts/tags.json") - versionCtx <- ContextValue.fromFile("contexts/version.json") - validationCtx <- ContextValue.fromFile("contexts/validation.json") + errorCtx <- ContextValue.fromFile("contexts/error.json") + metadataCtx <- ContextValue.fromFile("contexts/metadata.json") + searchCtx <- ContextValue.fromFile("contexts/search.json") + pipelineCtx <- ContextValue.fromFile("contexts/pipeline.json") + remoteContextsCtx <- ContextValue.fromFile("contexts/remote-contexts.json") + tagsCtx <- ContextValue.fromFile("contexts/tags.json") + versionCtx <- ContextValue.fromFile("contexts/version.json") + validationCtx <- ContextValue.fromFile("contexts/validation.json") } yield RemoteContextResolution .fixed( - contexts.error -> errorCtx, - contexts.metadata -> metadataCtx, - contexts.search -> searchCtx, - contexts.pipeline -> pipelineCtx, - contexts.tags -> tagsCtx, - contexts.version -> versionCtx, - contexts.validation -> validationCtx + contexts.error -> errorCtx, + contexts.metadata -> metadataCtx, + contexts.search -> searchCtx, + contexts.pipeline -> pipelineCtx, + contexts.remoteContexts -> remoteContextsCtx, + contexts.tags -> tagsCtx, + contexts.version -> versionCtx, + contexts.validation -> validationCtx ) .merge(otherCtxResolutions.toSeq: _*) } diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutesSpec.scala index 5ba78a51b1..69a61036b2 100644 --- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutesSpec.scala +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/IdentitiesRoutesSpec.scala @@ -11,10 +11,16 @@ import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfExceptionHandler import ch.epfl.bluebrain.nexus.delta.sdk.utils.{RouteFixtures, RouteHelpers} import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group} -import ch.epfl.bluebrain.nexus.testkit.{CirceEq, IOValues} +import ch.epfl.bluebrain.nexus.testkit.{CirceEq, IOValues, TestHelpers} import org.scalatest.matchers.should.Matchers -class IdentitiesRoutesSpec extends RouteHelpers with Matchers with CirceEq with RouteFixtures with IOValues { +class IdentitiesRoutesSpec + extends RouteHelpers + with Matchers + with CirceEq + with RouteFixtures + with IOValues + with TestHelpers { private val caller = Caller(alice, Set(alice, Anonymous, Authenticated(realm), Group("group", realm))) diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala index 111b9bf63c..92e26ef571 100644 --- a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/ResourcesRoutesSpec.scala @@ -323,24 +323,19 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap { } } - "fail fetching a resource without resources/read permission" in { + "fail fetching a resource information without resources/read permission" in { val endpoints = List( "/v1/resources/myorg/myproject/_/myid2", - s"/v1/resources/myorg/myproject/myschema/$myId2Encoded" + "/v1/resources/myorg/myproject/_/myid2?rev=1", + "/v1/resources/myorg/myproject/_/myid2?tag=mytag", + s"/v1/resources/myorg/myproject/myschema/$myId2Encoded", + "/v1/resources/myorg/myproject/_/myid2/source", + "/v1/resources/myorg/myproject/_/myid2/source?annotate=true", + "/v1/resources/myorg/myproject/_/myid2/remote-contexts", + "/v1/resources/myorg/myproject/_/myid2/tags" ) forAll(endpoints) { endpoint => - forAll(List("", "?rev=1", "?tag=mytag")) { suffix => - Get(s"$endpoint$suffix") ~> routes ~> check { - response.status shouldEqual StatusCodes.Forbidden - response.asJson shouldEqual jsonContentOf("errors/authorization-failed.json") - } - } - } - } - - "fail fetching a resource original payload without resources/read permission" in { - forAll(List("", "?annotate=true")) { suffix => - Get(s"/v1/resources/myorg/myproject/_/myid2/source$suffix") ~> routes ~> check { + Get(endpoint) ~> routes ~> check { response.status shouldEqual StatusCodes.Forbidden response.asJson shouldEqual jsonContentOf("errors/authorization-failed.json") } @@ -383,14 +378,60 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap { } } - "return not found if fetching a resource original payload that does not exist" in { - Get("/v1/resources/myorg/myproject/_/wrongid/source") ~> routes ~> check { - status shouldEqual StatusCodes.NotFound - response.asJson shouldEqual jsonContentOf( - "/resources/errors/not-found.json", - "id" -> "https://bluebrain.github.io/nexus/vocabulary/wrongid", - "proj" -> "myorg/myproject" - ) + "fetch a resource remote contexts" in { + val suffix = genString() + val idWithRemoteContext = nxv + suffix + val payload = jsonContentOf("resources/resource.json", "id" -> idWithRemoteContext).deepMerge(resourceCtx) + Post("/v1/resources/myorg/myproject", payload.toEntity) ~> routes ~> check { + status shouldEqual StatusCodes.Created + } + + val tag = "mytag" + val tagPayload = json"""{"tag": "$tag", "rev": 1}""" + Post(s"/v1/resources/myorg/myproject/_/$suffix/tags?rev=1", tagPayload.toEntity) ~> routes ~> check { + status shouldEqual StatusCodes.Created + } + + val endpoints = List( + s"/v1/resources/myorg/myproject/_/$suffix/remote-contexts", + s"/v1/resources/myorg/myproject/_/$suffix/remote-contexts?rev=1", + s"/v1/resources/myorg/myproject/_/$suffix/remote-contexts?tag=$tag" + ) + + forAll(endpoints) { endpoint => + Get(endpoint) ~> routes ~> check { + status shouldEqual StatusCodes.OK + response.asJson shouldEqual + json"""{ + "@context" : "https://bluebrain.github.io/nexus/contexts/remote-contexts.json", + "remoteContexts" : [ + { "@type": "StaticContextRef", "iri": "https://bluebrain.github.io/nexus/contexts/metadata.json" } + ] + }""" + } + } + + } + + "return not found when a resource does not exist" in { + val endpoints = List( + "/v1/resources/myorg/myproject/_/wrongid", + "/v1/resources/myorg/myproject/_/wrongid?rev=1", + "/v1/resources/myorg/myproject/_/wrongid?tag=mytag", + "/v1/resources/myorg/myproject/_/wrongid/source", + "/v1/resources/myorg/myproject/_/wrongid/source?annotate=true", + "/v1/resources/myorg/myproject/_/wrongid/remote-contexts", + "/v1/resources/myorg/myproject/_/wrongid/tags" + ) + forAll(endpoints) { endpoint => + Get(endpoint) ~> routes ~> check { + status shouldEqual StatusCodes.NotFound + response.asJson shouldEqual jsonContentOf( + "/resources/errors/not-found.json", + "id" -> "https://bluebrain.github.io/nexus/vocabulary/wrongid", + "proj" -> "myorg/myproject" + ) + } } } diff --git a/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/Vocabulary.scala b/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/Vocabulary.scala index 33784e658c..97f4977909 100644 --- a/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/Vocabulary.scala +++ b/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/Vocabulary.scala @@ -230,6 +230,7 @@ object Vocabulary { val quotas = contexts + "quotas.json" val realms = contexts + "realms.json" val realmsMetadata = contexts + "realms-metadata.json" + val remoteContexts = contexts + "remote-contexts.json" val resolvers = contexts + "resolvers.json" val resolversMetadata = contexts + "resolvers-metadata.json" val search = contexts + "search.json" diff --git a/delta/sdk/src/main/resources/contexts/remote-contexts.json b/delta/sdk/src/main/resources/contexts/remote-contexts.json new file mode 100644 index 0000000000..641de69e4f --- /dev/null +++ b/delta/sdk/src/main/resources/contexts/remote-contexts.json @@ -0,0 +1,9 @@ +{ + "@context": { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/", + "remoteContexts": { + "@container": "@set" + } + }, + "@id": "https://bluebrain.github.io/nexus/contexts/remote-contexts.json" +} \ No newline at end of file diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/jsonld/RemoteContextRef.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/jsonld/RemoteContextRef.scala index 41babe382c..289adc04be 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/jsonld/RemoteContextRef.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/model/jsonld/RemoteContextRef.scala @@ -1,15 +1,18 @@ package ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld -import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContext +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.{BNode, Iri} +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContext.StaticContext -import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef.NexusContextRef.ResourceContext -import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.NexusContext +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContext} +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef.ProjectRemoteContextRef.ResourceContext +import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.ProjectRemoteContext import ch.epfl.bluebrain.nexus.delta.sourcing.Serializer import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef -import io.circe.Codec import io.circe.generic.extras.Configuration import io.circe.generic.extras.semiauto.deriveConfiguredCodec +import io.circe.syntax.EncoderOps +import io.circe.{Codec, Encoder, Json, JsonObject} import scala.annotation.nowarn @@ -28,10 +31,10 @@ object RemoteContextRef { def apply(remoteContexts: Map[Iri, RemoteContext]): Set[RemoteContextRef] = remoteContexts.foldLeft(Set.empty[RemoteContextRef]) { - case (acc, (input, _: StaticContext)) => acc + StaticContextRef(input) - case (acc, (input, context: NexusContext)) => - acc + NexusContextRef(input, ResourceContext(context.iri, context.project, context.rev)) - case (_, (_, context)) => + case (acc, (input, _: StaticContext)) => acc + StaticContextRef(input) + case (acc, (input, context: ProjectRemoteContext)) => + acc + ProjectRemoteContextRef(input, ResourceContext(context.iri, context.project, context.rev)) + case (_, (_, context)) => throw new NotImplementedError(s"Case for '${context.getClass.getSimpleName}' has not been implemented.") } @@ -41,15 +44,15 @@ object RemoteContextRef { final case class StaticContextRef(iri: Iri) extends RemoteContextRef /** - * A reference to a context registered in Nexus - * @param id - * the identifier it has been resolved to + * A reference to a context registered in a Nexus project + * @param iri + * the resolved iri * @param resource - * the qualified reference to the revisioned Nexus resource + * the qualified reference to the Nexus resource */ - final case class NexusContextRef(iri: Iri, resource: ResourceContext) extends RemoteContextRef + final case class ProjectRemoteContextRef(iri: Iri, resource: ResourceContext) extends RemoteContextRef - object NexusContextRef { + object ProjectRemoteContextRef { final case class ResourceContext(id: Iri, project: ProjectRef, rev: Int) } @@ -59,4 +62,12 @@ object RemoteContextRef { implicit val resourceContextRefCoder = deriveConfiguredCodec[ResourceContext] deriveConfiguredCodec[RemoteContextRef] } + + implicit final val remoteContextRefsJsonLdEncoder: JsonLdEncoder[Set[RemoteContextRef]] = { + implicit val remoteContextsEncoder: Encoder.AsObject[Set[RemoteContextRef]] = Encoder.AsObject.instance { + remoteContexts => + JsonObject("remoteContexts" -> Json.arr(remoteContexts.map(_.asJson).toSeq: _*)) + } + JsonLdEncoder.computeFromCirce(id = BNode.random, ctx = ContextValue(contexts.remoteContexts)) + } } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala index b3ccb55ef5..b5e98c4ee2 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolution.scala @@ -8,7 +8,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteCon import ch.epfl.bluebrain.nexus.delta.sdk._ import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller -import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.{logger, NexusContext} +import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.{logger, ProjectRemoteContext} import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverResolution.ResourceResolution import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.model.ResourceResolutionReport import ch.epfl.bluebrain.nexus.delta.sdk.resources.Resources @@ -51,7 +51,7 @@ final class ResolverContextResolution(val rcr: RemoteContextResolution, resolveR s"Resolution via static resolution and via resolvers failed in '$projectRef'", Some(report.asJson) ), - NexusContext.fromResource + ProjectRemoteContext.fromResource ) ) .tapEval { context => @@ -70,11 +70,17 @@ object ResolverContextResolution { /** * A remote context defined in Nexus as a resource */ - final case class NexusContext(iri: Iri, project: ProjectRef, rev: Int, value: ContextValue) extends RemoteContext + final case class ProjectRemoteContext(iri: Iri, project: ProjectRef, rev: Int, value: ContextValue) + extends RemoteContext - object NexusContext { - def fromResource(resource: DataResource): NexusContext = - NexusContext(resource.id, resource.value.project, resource.rev, resource.value.source.topContextValueOrEmpty) + object ProjectRemoteContext { + def fromResource(resource: DataResource): ProjectRemoteContext = + ProjectRemoteContext( + resource.id, + resource.value.project, + resource.rev, + resource.value.source.topContextValueOrEmpty + ) } /** diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala index 3ee0988f37..3a40497bc9 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/Resources.scala @@ -192,6 +192,23 @@ trait Resources { rev: Int )(implicit caller: Subject): IO[ResourceRejection, DataResource] + /** + * Fetches a resource state. + * + * @param id + * the identifier that will be expanded to the Iri of the resource with its optional rev/tag + * @param projectRef + * the project reference where the resource belongs + * @param schemaOpt + * the optional identifier that will be expanded to the schema reference of the resource. A None value uses the + * currently available resource schema reference. + */ + def fetchState( + id: IdSegmentRef, + projectRef: ProjectRef, + schemaOpt: Option[IdSegment] + ): IO[ResourceFetchRejection, ResourceState] + /** * Fetches a resource. * diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImpl.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImpl.scala index 6a2cdf4393..a52fd550ff 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImpl.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImpl.scala @@ -157,11 +157,11 @@ final class ResourcesImpl private ( res <- eval(DeprecateResource(iri, projectRef, schemeRefOpt, rev, caller)) } yield res).span("deprecateResource") - override def fetch( + def fetchState( id: IdSegmentRef, projectRef: ProjectRef, schemaOpt: Option[IdSegment] - ): IO[ResourceFetchRejection, DataResource] = { + ): IO[ResourceFetchRejection, ResourceState] = { for { pc <- fetchContext.onRead(projectRef) iri <- expandIri(id.value, pc) @@ -175,9 +175,15 @@ final class ResourcesImpl private ( log.stateOr(projectRef, iri, tag, notFound, TagNotFound(tag)) } _ <- IO.raiseWhen(schemaRefOpt.exists(_.iri != state.schema.iri))(notFound) - } yield state.toResource + } yield state }.span("fetchResource") + override def fetch( + id: IdSegmentRef, + projectRef: ProjectRef, + schemaOpt: Option[IdSegment] + ): IO[ResourceFetchRejection, DataResource] = fetchState(id, projectRef, schemaOpt).map(_.toResource) + private def eval(cmd: ResourceCommand): IO[ResourceRejection, DataResource] = log.evaluate(cmd.project, cmd.id, cmd).map(_._2.toResource) diff --git a/delta/sdk/src/test/resources/resources/database/resource-created.json b/delta/sdk/src/test/resources/resources/database/resource-created.json index 1c113427d6..d8019a3e36 100644 --- a/delta/sdk/src/test/resources/resources/database/resource-created.json +++ b/delta/sdk/src/test/resources/resources/database/resource-created.json @@ -55,7 +55,7 @@ "project": "myorg/myproj", "rev": 5 }, - "@type": "NexusContextRef" + "@type": "ProjectRemoteContextRef" } ], "rev": 1, diff --git a/delta/sdk/src/test/resources/resources/database/resource-refreshed.json b/delta/sdk/src/test/resources/resources/database/resource-refreshed.json index 202b8357ae..de0d4f7f38 100644 --- a/delta/sdk/src/test/resources/resources/database/resource-refreshed.json +++ b/delta/sdk/src/test/resources/resources/database/resource-refreshed.json @@ -43,7 +43,7 @@ "project": "myorg/myproj", "rev": 5 }, - "@type": "NexusContextRef" + "@type": "ProjectRemoteContextRef" } ], "rev": 2, diff --git a/delta/sdk/src/test/resources/resources/database/resource-updated.json b/delta/sdk/src/test/resources/resources/database/resource-updated.json index dc9b3a6c98..27ab18e5d2 100644 --- a/delta/sdk/src/test/resources/resources/database/resource-updated.json +++ b/delta/sdk/src/test/resources/resources/database/resource-updated.json @@ -55,7 +55,7 @@ "project": "myorg/myproj", "rev": 5 }, - "@type": "NexusContextRef" + "@type": "ProjectRemoteContextRef" } ], "rev": 2, diff --git a/delta/sdk/src/test/resources/resources/resource-state.json b/delta/sdk/src/test/resources/resources/resource-state.json index 9b67ebbdf0..3b379a895f 100644 --- a/delta/sdk/src/test/resources/resources/resource-state.json +++ b/delta/sdk/src/test/resources/resources/resource-state.json @@ -51,7 +51,7 @@ "project": "myorg/myproj", "rev": 5 }, - "@type": "NexusContextRef" + "@type": "ProjectRemoteContextRef" } ], "rev": 2, diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolutionSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolutionSpec.scala index c8614c277c..837c265b17 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolutionSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resolvers/ResolverContextResolutionSpec.scala @@ -11,7 +11,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.generators.ResourceResolutionGen import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ import ch.epfl.bluebrain.nexus.delta.sdk.model._ -import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.NexusContext +import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.ProjectRemoteContext import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverResolution.FetchResource import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.Resource import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User @@ -82,7 +82,7 @@ class ResolverContextResolutionSpec extends AnyWordSpecLike with IOValues with T } "resolve correctly a resource context" in { - val expected = NexusContext(resourceId, project, 5, ContextValue(context)) + val expected = ProjectRemoteContext(resourceId, project, 5, ContextValue(context)) resolverContextResolution(project).resolve(resourceId).accepted shouldEqual expected } diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourceFixture.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourceFixture.scala index 968a13193b..fd98f9adfe 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourceFixture.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourceFixture.scala @@ -8,7 +8,7 @@ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContext._ import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContext} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.{CompactedJsonLd, ExpandedJsonLd} import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef -import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.NexusContext +import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution.ProjectRemoteContext import ch.epfl.bluebrain.nexus.delta.sdk.syntax._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef} import ch.epfl.bluebrain.nexus.testkit.CirceLiteral @@ -62,7 +62,7 @@ private[resources] object ResourceFixture extends CirceLiteral { CompactedJsonLd.unsafe(myId, compactedObj.topContextValueOrEmpty, compactedObj.remove(keywords.context)) val remoteContexts: Map[Iri, RemoteContext] = Map( staticContext -> StaticContext(staticContext, ContextValue.empty), - nexusContext -> NexusContext(nexusContext, projectRef, 5, ContextValue.empty) + nexusContext -> ProjectRemoteContext(nexusContext, projectRef, 5, ContextValue.empty) ) val remoteContextRefs: Set[RemoteContextRef] = RemoteContextRef(remoteContexts) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala index 6590dac9d2..97aa1d58f2 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/utils/RouteFixtures.scala @@ -12,41 +12,41 @@ import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri import ch.epfl.bluebrain.nexus.delta.sdk.model.search.PaginationConfig import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label -import ch.epfl.bluebrain.nexus.testkit.{IOValues, TestHelpers} import monix.execution.Scheduler -trait RouteFixtures extends TestHelpers with IOValues { +trait RouteFixtures { implicit private val cl: ClassLoader = getClass.getClassLoader implicit val api: JsonLdApi = JsonLdJavaApi.strict implicit def rcr: RemoteContextResolution = - RemoteContextResolution.fixed( - contexts.acls -> ContextValue.fromFile("contexts/acls.json").accepted, - contexts.aclsMetadata -> ContextValue.fromFile("contexts/acls-metadata.json").accepted, - contexts.metadata -> ContextValue.fromFile("contexts/metadata.json").accepted, - contexts.error -> ContextValue.fromFile("contexts/error.json").accepted, - contexts.validation -> ContextValue.fromFile("contexts/validation.json").accepted, - contexts.organizations -> ContextValue.fromFile("contexts/organizations.json").accepted, - contexts.organizationsMetadata -> ContextValue.fromFile("contexts/organizations-metadata.json").accepted, - contexts.identities -> ContextValue.fromFile("contexts/identities.json").accepted, - contexts.permissions -> ContextValue.fromFile("contexts/permissions.json").accepted, - contexts.permissionsMetadata -> ContextValue.fromFile("contexts/permissions-metadata.json").accepted, - contexts.projects -> ContextValue.fromFile("contexts/projects.json").accepted, - contexts.projectsMetadata -> ContextValue.fromFile("contexts/projects-metadata.json").accepted, - contexts.realms -> ContextValue.fromFile("contexts/realms.json").accepted, - contexts.realmsMetadata -> ContextValue.fromFile("contexts/realms-metadata.json").accepted, - contexts.resolvers -> ContextValue.fromFile("contexts/resolvers.json").accepted, - contexts.resolversMetadata -> ContextValue.fromFile("contexts/resolvers-metadata.json").accepted, - contexts.search -> ContextValue.fromFile("contexts/search.json").accepted, - contexts.shacl -> ContextValue.fromFile("contexts/shacl.json").accepted, - contexts.schemasMetadata -> ContextValue.fromFile("contexts/schemas-metadata.json").accepted, - contexts.offset -> ContextValue.fromFile("contexts/offset.json").accepted, - contexts.statistics -> ContextValue.fromFile("contexts/statistics.json").accepted, - contexts.supervision -> ContextValue.fromFile("contexts/supervision.json").accepted, - contexts.tags -> ContextValue.fromFile("contexts/tags.json").accepted, - contexts.version -> ContextValue.fromFile("/contexts/version.json").accepted, - contexts.quotas -> ContextValue.fromFile("/contexts/quotas.json").accepted + RemoteContextResolution.fixedIO( + contexts.acls -> ContextValue.fromFile("contexts/acls.json"), + contexts.aclsMetadata -> ContextValue.fromFile("contexts/acls-metadata.json"), + contexts.metadata -> ContextValue.fromFile("contexts/metadata.json"), + contexts.error -> ContextValue.fromFile("contexts/error.json"), + contexts.validation -> ContextValue.fromFile("contexts/validation.json"), + contexts.organizations -> ContextValue.fromFile("contexts/organizations.json"), + contexts.organizationsMetadata -> ContextValue.fromFile("contexts/organizations-metadata.json"), + contexts.identities -> ContextValue.fromFile("contexts/identities.json"), + contexts.permissions -> ContextValue.fromFile("contexts/permissions.json"), + contexts.permissionsMetadata -> ContextValue.fromFile("contexts/permissions-metadata.json"), + contexts.projects -> ContextValue.fromFile("contexts/projects.json"), + contexts.projectsMetadata -> ContextValue.fromFile("contexts/projects-metadata.json"), + contexts.realms -> ContextValue.fromFile("contexts/realms.json"), + contexts.realmsMetadata -> ContextValue.fromFile("contexts/realms-metadata.json"), + contexts.remoteContexts -> ContextValue.fromFile("contexts/remote-contexts.json"), + contexts.resolvers -> ContextValue.fromFile("contexts/resolvers.json"), + contexts.resolversMetadata -> ContextValue.fromFile("contexts/resolvers-metadata.json"), + contexts.search -> ContextValue.fromFile("contexts/search.json"), + contexts.shacl -> ContextValue.fromFile("contexts/shacl.json"), + contexts.schemasMetadata -> ContextValue.fromFile("contexts/schemas-metadata.json"), + contexts.offset -> ContextValue.fromFile("contexts/offset.json"), + contexts.statistics -> ContextValue.fromFile("contexts/statistics.json"), + contexts.supervision -> ContextValue.fromFile("contexts/supervision.json"), + contexts.tags -> ContextValue.fromFile("contexts/tags.json"), + contexts.version -> ContextValue.fromFile("/contexts/version.json"), + contexts.quotas -> ContextValue.fromFile("/contexts/quotas.json") ) implicit val ordering: JsonKeyOrdering = diff --git a/docs/src/main/paradox/docs/delta/api/assets/remote-contexts.json b/docs/src/main/paradox/docs/delta/api/assets/remote-contexts.json new file mode 100644 index 0000000000..aa5e30ff6f --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/remote-contexts.json @@ -0,0 +1,18 @@ +{ + "@context": "https://bluebrain.github.io/nexus/contexts/remote-contexts.json", + "remoteContexts": [ + { + "@type": "StaticContextRef", + "iri": "https://bluebrain.github.io/nexus/contexts/metadata.json" + }, + { + "@type": "ProjectContextRef", + "iri": "https://localhost/nexus/context", + "resource": { + "id": "https://localhost/nexus/context", + "project": "org/proj", + "rev": 5 + } + } + ] +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/resources/remote-contexts.sh b/docs/src/main/paradox/docs/delta/api/assets/resources/remote-contexts.sh new file mode 100644 index 0000000000..ba4ddde342 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/resources/remote-contexts.sh @@ -0,0 +1 @@ +curl "http://localhost:8080/v1/resources/org/proj/_/my-resource/remote-contexts" diff --git a/docs/src/main/paradox/docs/delta/api/resources-api.md b/docs/src/main/paradox/docs/delta/api/resources-api.md index 614823503c..e782c600b2 100644 --- a/docs/src/main/paradox/docs/delta/api/resources-api.md +++ b/docs/src/main/paradox/docs/delta/api/resources-api.md @@ -273,6 +273,34 @@ Request Response : @@snip [fetched.json](assets/resources/payload.json) +## Fetch remote contexts + +Returns the remote contexts that have been detected during the JSON-LD resolution for this resource. + +These contexts can be: + +* Static contexts that are statically defined in Nexus +* Project contexts that have been registered by Nexus, in this case the entry also provides the project this context lives +and its revision at the time the JSON-LD resolution has been performed + +``` +GET /v1/resources/{org_label}/{project_label}/{schema_id}/{resource_id}/remote-contexts?rev={rev}&tag={tag} +``` +where ... + +- `{rev}`: Number - the targeted revision to be fetched. This field is optional and defaults to the latest revision. +- `{tag}`: String - the targeted tag to be fetched. This field is optional. + +`{rev}` and `{tag}` fields cannot be simultaneously present. + +**Example** + +Request +: @@snip [fetchTags.sh](assets/resources/remote-contexts.sh) + +Response +: @@snip [tags.json](assets/remote-contexts.json) + ## Fetch tags ``` diff --git a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md index 7cf3b86289..aadc71f27b 100644 --- a/docs/src/main/paradox/docs/releases/v1.9-release-notes.md +++ b/docs/src/main/paradox/docs/releases/v1.9-release-notes.md @@ -42,6 +42,12 @@ It is now possible to aggregate resources by `@type` or `project`. @ref:[More information](../delta/api/resources-api.md#aggregations) +#### Remote contexts + +When creating/updating a resource, Nexus Delta now keeps track of the remote contexts that have been resolved during the operation. + +@ref:[More information](../delta/api/resources-api.md#fetch-remote-contexts) + ### Views #### Indexing errors listing diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/ResourcesSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/ResourcesSpec.scala index 0e4cdccbfd..e4f8c3b5a2 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/ResourcesSpec.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/ResourcesSpec.scala @@ -14,13 +14,11 @@ import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.Organizations import ch.epfl.bluebrain.nexus.tests.{BaseSpec, Optics, SchemaPayload} import io.circe.Json import io.circe.optics.JsonPath.root -import monix.bio.Task import monix.execution.Scheduler.Implicits.global import monocle.Optional import org.scalatest.matchers.{HavePropertyMatchResult, HavePropertyMatcher} import java.net.URLEncoder -import scala.concurrent.duration._ class ResourcesSpec extends BaseSpec with EitherValuable with CirceEq { @@ -512,7 +510,6 @@ class ResourcesSpec extends BaseSpec with EitherValuable with CirceEq { (_, response) => response.status shouldEqual StatusCodes.Created } - _ <- Task.sleep(100.millis) _ <- deltaClient.get[Json](s"/resources/$id1/test-schema", Rick) { (json, response) => response.status shouldEqual StatusCodes.OK val received = json.asObject.value("_total").value.asNumber.value.toInt.value @@ -539,6 +536,30 @@ class ResourcesSpec extends BaseSpec with EitherValuable with CirceEq { } } + "fetch remote contexts for the created resource" in { + deltaClient.get[Json](s"/resources/$id1/_/myid/remote-contexts", Rick) { (json, response) => + response.status shouldEqual StatusCodes.OK + val expected = + json""" + { + "@context": "https://bluebrain.github.io/nexus/contexts/remote-contexts.json", + "remoteContexts": [ + { + "@type": "ProjectRemoteContextRef", + "iri": "https://dev.nexus.test.com/simplified-resource/mycontext", + "resource": { + "id": "https://dev.nexus.test.com/simplified-resource/mycontext", + "project": "$id1", + "rev": 1 + } + } + ] + }""" + + json shouldEqual expected + } + } + "get a redirect to fusion if a `text/html` header is provided" in { deltaClient.get[String](