diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index e5c129917f..43ec0ca1a5 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -256,6 +256,8 @@ app { event-log = ${app.defaults.event-log} # Reject payloads which contain nexus metadata fields (any field beginning with _) decoding-option = "strict" + # Do not create a new revision of a resource when the update does not introduce a change + skip-update-no-change = true } # Schemas configuration diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/ResourcesModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/ResourcesModule.scala index 431f10f967..ad2300f379 100644 --- a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/ResourcesModule.scala +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/ResourcesModule.scala @@ -23,7 +23,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverResolution.ResourceRe import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.{ResolverContextResolution, Resolvers, ResourceResolution} import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.ProjectContextRejection import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.{Resource, ResourceEvent} -import ch.epfl.bluebrain.nexus.delta.sdk.resources.{Resources, ResourcesConfig, ResourcesImpl, ValidateResource} +import ch.epfl.bluebrain.nexus.delta.sdk.resources.{DetectChange, Resources, ResourcesConfig, ResourcesImpl, ValidateResource} import ch.epfl.bluebrain.nexus.delta.sdk.schemas.Schemas import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.Schema import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder @@ -41,9 +41,12 @@ object ResourcesModule extends ModuleDef { make[ResourcesConfig].from { (config: AppConfig) => config.resources } + make[DetectChange].from { (config: ResourcesConfig) => DetectChange(config.skipUpdateNoChange) } + make[Resources].from { ( validate: ValidateResource, + detectChange: DetectChange, fetchContext: FetchContext[ContextRejection], config: ResourcesConfig, resolverContextResolution: ResolverContextResolution, @@ -54,6 +57,7 @@ object ResourcesModule extends ModuleDef { ) => ResourcesImpl( validate, + detectChange, fetchContext.mapRejection(ProjectContextRejection), resolverContextResolution, config, 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 f0723123c8..6992400411 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 @@ -26,7 +26,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverContextResolution import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverResolution.FetchResource import ch.epfl.bluebrain.nexus.delta.sdk.resources.NexusSource.DecodingOption import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.ProjectContextRejection -import ch.epfl.bluebrain.nexus.delta.sdk.resources.{Resources, ResourcesConfig, ResourcesImpl, ValidateResource} +import ch.epfl.bluebrain.nexus.delta.sdk.resources.{DetectChange, Resources, ResourcesConfig, ResourcesImpl, ValidateResource} import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.Schema import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, Subject, User} @@ -106,9 +106,10 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap with CatsIOValues private def routesWithDecodingOption(implicit decodingOption: DecodingOption): (Route, Resources) = { val resources = ResourcesImpl( validator, + DetectChange(enabled = true), fetchContext, resolverContextResolution, - ResourcesConfig(eventLogConfig, decodingOption), + ResourcesConfig(eventLogConfig, decodingOption, skipUpdateNoChange = true), xas, clock ) diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssembly.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssembly.scala index 4353a5e069..83f452c550 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssembly.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssembly.scala @@ -1,15 +1,14 @@ package ch.epfl.bluebrain.nexus.delta.sdk.jsonld -import cats.Eq import cats.effect.IO import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri import ch.epfl.bluebrain.nexus.delta.rdf.RdfError -import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdRejection._ import ch.epfl.bluebrain.nexus.delta.rdf.graph.Graph import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.JsonLdApi import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContext, RemoteContextResolution} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.{CompactedJsonLd, ExpandedJsonLd} +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdRejection._ import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef import io.circe.Json @@ -27,7 +26,7 @@ import io.circe.Json * @param graph * its graph representation * @param remoteContexts - * it + * the resolved remote contexts */ final case class JsonLdAssembly( id: Iri, @@ -46,20 +45,6 @@ final case class JsonLdAssembly( object JsonLdAssembly { - /** - * Defines the equality between two instances - * - * - If the remote contexts and the local context are the same, then the compacted form will be the same - * - If the graph forms are isomorphic then, the expanded form will be the same - */ - implicit val jsonLdAssemblyEq: Eq[JsonLdAssembly] = Eq.instance { (jsonld1, jsonld2) => - jsonld1.id == jsonld2.id && - jsonld1.remoteContexts == jsonld2.remoteContexts && - jsonld1.compacted.ctx == jsonld2.compacted.ctx && - jsonld1.graph.isIsomorphic(jsonld2.graph) && - jsonld1.source == jsonld2.source - } - def apply( iri: Iri, source: Json, diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdSourceProcessor.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdSourceProcessor.scala index e74d214666..bd574121d3 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdSourceProcessor.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdSourceProcessor.scala @@ -31,21 +31,22 @@ sealed abstract class JsonLdSourceProcessor(implicit api: JsonLdApi) { protected def getOrGenerateId(iri: Option[Iri], context: ProjectContext): IO[Iri] = iri.fold(uuidF().map(uuid => context.base.iri / uuid.toString))(IO.pure) - protected def expandSource( - context: ProjectContext, - source: Json - )(implicit rcr: RemoteContextResolution): IO[(ContextValue, ExplainResult[ExpandedJsonLd])] = { - implicit val opts: JsonLdOptions = JsonLdOptions(base = Some(context.base.iri)) - ExpandedJsonLd - .explain(source) - .flatMap { - case result if result.value.isEmpty && source.topContextValueOrEmpty.isEmpty => - val ctx = defaultCtx(context) - ExpandedJsonLd.explain(source.addContext(ctx.contextObj)).map(ctx -> _) - case result => - IO.pure(source.topContextValueOrEmpty -> result) - } - .adaptError { case err: RdfError => InvalidJsonLdFormat(None, err) } + /** + * Expand the source document using the provided project context and remote context resolution. + * + * If the source does not provide a context, one will be injected from the project base and vocab. + */ + protected def expandSource(projectContext: ProjectContext, source: Json)(implicit + rcr: RemoteContextResolution + ): IO[(ContextValue, ExplainResult[ExpandedJsonLd])] = { + implicit val opts: JsonLdOptions = JsonLdOptions(base = Some(projectContext.base.iri)) + val sourceContext = source.topContextValueOrEmpty + if (sourceContext.isEmpty) { + val defaultContext = defaultCtx(projectContext) + ExpandedJsonLd.explain(source.addContext(defaultContext.contextObj)).map(defaultContext -> _) + } else { + ExpandedJsonLd.explain(source).map(sourceContext -> _) + }.adaptError { case err: RdfError => InvalidJsonLdFormat(None, err) } } protected def checkAndSetSameId(iri: Iri, expanded: ExpandedJsonLd): IO[ExpandedJsonLd] = diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChange.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChange.scala new file mode 100644 index 0000000000..277c9e4230 --- /dev/null +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChange.scala @@ -0,0 +1,65 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.resources + +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.CompactedJsonLd +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdAssembly +import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef +import ch.epfl.bluebrain.nexus.delta.sdk.resources.DetectChange.Current +import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceState +import io.circe.Json + +/** + * Detect if the new json-ld state introduces changes compared to the current state + */ +trait DetectChange { + def apply(newValue: JsonLdAssembly, currentState: ResourceState): IO[Boolean] = + apply( + newValue, + Current(currentState.types, currentState.source, currentState.compacted, currentState.remoteContexts) + ) + + def apply(newValue: JsonLdAssembly, current: Current): IO[Boolean] +} + +object DetectChange { + + final case class Current( + types: Set[Iri], + source: Json, + compacted: CompactedJsonLd, + remoteContexts: Set[RemoteContextRef] + ) + + private val Disabled = new DetectChange { + + override def apply(newValue: JsonLdAssembly, current: Current): IO[Boolean] = IO.pure(true) + } + + /** + * Default implementation + * + * There will be a change if: + * - If there is a change in the resource types + * - If there is a change in one of the remote JSON-LD contexts + * - If there is a change in the local JSON-LD context + * - If there is a change in the rest of the payload + * + * The implementation uses `IO.cede` as comparing source can induce expensive work in the case of large payloads. + */ + private val Impl = new DetectChange { + + override def apply(newValue: JsonLdAssembly, current: Current): IO[Boolean] = + IO.cede + .as( + newValue.types != current.types || + newValue.remoteContexts != current.remoteContexts || + newValue.compacted.ctx != current.compacted.ctx || + newValue.source != current.source + ) + .guarantee(IO.cede) + } + + def apply(enabled: Boolean): DetectChange = if (enabled) Impl else Disabled + +} diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/NexusSource.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/NexusSource.scala index 79faa5707e..c83d5b1026 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/NexusSource.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/NexusSource.scala @@ -2,8 +2,8 @@ package ch.epfl.bluebrain.nexus.delta.sdk.resources import io.circe.Decoder.Result import io.circe.{Decoder, DecodingFailure, HCursor, Json} -import pureconfig.error.{CannotConvert, ConfigReaderFailures, ConvertFailure} -import pureconfig.{ConfigCursor, ConfigReader} +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert final case class NexusSource(value: Json) extends AnyVal @@ -16,30 +16,20 @@ object NexusSource { final case object Lenient extends DecodingOption - implicit val decodingOptionConfigReader: ConfigReader[DecodingOption] = { - new ConfigReader[DecodingOption] { - private val stringReader = implicitly[ConfigReader[String]] - override def from(cur: ConfigCursor): ConfigReader.Result[DecodingOption] = { - stringReader.from(cur).flatMap { - case "strict" => Right(Strict) - case "lenient" => Right(Lenient) - case other => - Left( - ConfigReaderFailures( - ConvertFailure( - CannotConvert( - other, - "DecodingOption", - s"values can only be 'strict' or 'lenient'" - ), - cur - ) - ) - ) - } - } + implicit val decodingOptionConfigReader: ConfigReader[DecodingOption] = + ConfigReader.fromString { + case "strict" => Right(Strict) + case "lenient" => Right(Lenient) + case other => + Left( + CannotConvert( + other, + "DecodingOption", + s"values can only be 'strict' or 'lenient'" + ) + ) + } - } } private val strictDecoder = new Decoder[NexusSource] { 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 190fc5f174..2f8fcd32e5 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 @@ -351,6 +351,7 @@ object Resources { @SuppressWarnings(Array("OptionGet")) private[delta] def evaluate( validateResource: ValidateResource, + detectChange: DetectChange, clock: Clock[IO] )(state: Option[ResourceState], cmd: ResourceCommand): IO[ResourceEvent] = { @@ -432,10 +433,9 @@ object Resources { } for { - state <- stateWhereResourceIsEditable(u) - stateJsonLd <- IO.fromEither(state.toAssembly) - changeDetected = sys.env.get("DISABLE_CHANGE_DETECTION").contains("true") || stateJsonLd =!= u.jsonld - event <- if (u.schemaOpt.isDefined || changeDetected) onChange(state) else fallbackToTag(state) + state <- stateWhereResourceIsEditable(u) + changeDetected <- detectChange(u.jsonld, state) + event <- if (u.schemaOpt.isDefined || changeDetected) onChange(state) else fallbackToTag(state) } yield event } @@ -452,10 +452,10 @@ object Resources { def refresh(r: RefreshResource) = { for { state <- stateWhereResourceIsEditable(r) - stateJsonLd <- IO.fromEither(state.toAssembly) _ <- raiseWhenDifferentSchema(r, state) (schemaRev, schemaProject) <- validate(r.jsonld, r.schemaOpt.getOrElse(state.schema), state.project, r.caller) - _ <- IO.raiseWhen(stateJsonLd === r.jsonld)(NoChangeDetected(state)) + changeDetected <- detectChange(r.jsonld, state) + _ <- IO.raiseUnless(changeDetected)(NoChangeDetected(state)) time <- clock.realTimeInstant } yield ResourceRefreshed(r.project, schemaRev, schemaProject, r.jsonld, state.rev + 1, time, r.subject) } @@ -511,11 +511,12 @@ object Resources { */ def definition( validateResource: ValidateResource, + detectChange: DetectChange, clock: Clock[IO] ): ScopedEntityDefinition[Iri, ResourceState, ResourceCommand, ResourceEvent, ResourceRejection] = ScopedEntityDefinition( entityType, - StateMachine(None, evaluate(validateResource, clock)(_, _), next), + StateMachine(None, evaluate(validateResource, detectChange, clock)(_, _), next), ResourceEvent.serializer, ResourceState.serializer, Tagger[ResourceEvent]( diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesConfig.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesConfig.scala index 227b077e1f..e011e5fe83 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesConfig.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesConfig.scala @@ -10,8 +10,12 @@ import pureconfig.generic.semiauto.deriveReader * * @param eventLog * configuration of the event log + * @param decodingOption + * strict/lenient decoding of resources + * @param skipUpdateNoChange + * do not create a new revision when the update does not introduce a change in the current resource state */ -final case class ResourcesConfig(eventLog: EventLogConfig, decodingOption: DecodingOption) +final case class ResourcesConfig(eventLog: EventLogConfig, decodingOption: DecodingOption, skipUpdateNoChange: Boolean) object ResourcesConfig { implicit final val resourcesConfigReader: ConfigReader[ResourcesConfig] = 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 86de05a330..a96356aebc 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 @@ -223,6 +223,7 @@ object ResourcesImpl { */ final def apply( validateResource: ValidateResource, + detectChange: DetectChange, fetchContext: FetchContext[ProjectContextRejection], contextResolution: ResolverContextResolution, config: ResourcesConfig, @@ -233,7 +234,7 @@ object ResourcesImpl { uuidF: UUIDF = UUIDF.random ): Resources = new ResourcesImpl( - ScopedEventLog(Resources.definition(validateResource, clock), config.eventLog, xas), + ScopedEventLog(Resources.definition(validateResource, detectChange, clock), config.eventLog, xas), fetchContext, JsonLdSourceResolvingParser[ResourceRejection](contextResolution, uuidF) ) diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssemblySuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssemblySuite.scala deleted file mode 100644 index 7828e4c65e..0000000000 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/jsonld/JsonLdAssemblySuite.scala +++ /dev/null @@ -1,70 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.sdk.jsonld - -import cats.syntax.all._ -import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv -import ch.epfl.bluebrain.nexus.delta.rdf.graph.Graph -import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} -import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue -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.model.jsonld.RemoteContextRef.StaticContextRef -import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite -import io.circe.syntax.KeyOps -import io.circe.{Json, JsonObject} - -class JsonLdAssemblySuite extends NexusSuite { - - implicit val jsonLdApi: JsonLdApi = JsonLdJavaApi.lenient - - private val id = nxv + "id" - - private val jsonld = JsonLdAssembly( - nxv + "id", - Json.obj("source" := "value"), - CompactedJsonLd.unsafe(id, ContextValue(nxv + "context"), JsonObject("field" := "value")), - ExpandedJsonLd.empty, - Graph.empty(id), - Set(StaticContextRef(nxv + "static")) - ) - - test("A jsonld assembly is equal to itself") { - assert(jsonld === jsonld) - } - - test("Two jsonld assemblies are not equals if the id is different") { - val other = jsonld.copy(id = nxv + "another-id") - assert(jsonld =!= other) - } - - test("Two jsonld assemblies are not equals if the remote contexts are different") { - val otherRemoteContexts: Set[RemoteContextRef] = Set(StaticContextRef(nxv + "another-static")) - val other = jsonld.copy(remoteContexts = otherRemoteContexts) - assert(jsonld =!= other) - } - - test("Two jsonld assemblies are not equals if the context defined in the compacted form differs") { - val compacted = CompactedJsonLd.unsafe(id, ContextValue(nxv + "another-context"), JsonObject("field" := "value")) - val other = jsonld.copy(compacted = compacted) - assert(jsonld =!= other) - } - - test("Two jsonld assemblies are not equals if the context defined in the compacted form differs") { - val compacted = CompactedJsonLd.unsafe(id, ContextValue(nxv + "another-context"), JsonObject("field" := "value")) - val other = jsonld.copy(compacted = compacted) - assert(jsonld =!= other) - } - - test("Two jsonld assemblies are not equals if the graph forms are not isomorphic") { - val graph = Graph.empty(id).add(nxv + "new", "value") - val other = jsonld.copy(graph = graph) - assert(jsonld =!= other) - } - - test("Two jsonld assemblies are not equals if the original source are different") { - val source = Json.obj("source" := "another-value") - val other = jsonld.copy(source = source) - assert(jsonld =!= other) - } - -} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChangeSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChangeSuite.scala new file mode 100644 index 0000000000..a558299d72 --- /dev/null +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/DetectChangeSuite.scala @@ -0,0 +1,63 @@ +package ch.epfl.bluebrain.nexus.delta.sdk.resources + +import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.nxv +import ch.epfl.bluebrain.nexus.delta.rdf.graph.Graph +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.api.{JsonLdApi, JsonLdJavaApi} +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.{CompactedJsonLd, ExpandedJsonLd} +import ch.epfl.bluebrain.nexus.delta.sdk.jsonld.JsonLdAssembly +import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef +import ch.epfl.bluebrain.nexus.delta.sdk.model.jsonld.RemoteContextRef.StaticContextRef +import ch.epfl.bluebrain.nexus.testkit.mu.NexusSuite +import io.circe.syntax.KeyOps +import io.circe.{Json, JsonObject} + +class DetectChangeSuite extends NexusSuite { + + implicit val jsonLdApi: JsonLdApi = JsonLdJavaApi.lenient + + private val id = nxv + "id" + + private val source = Json.obj("source" := "value") + private val compacted = CompactedJsonLd.unsafe(id, ContextValue(nxv + "context"), JsonObject("field" := "value")) + private val remoteContexts: Set[RemoteContextRef] = Set(StaticContextRef(nxv + "static")) + + private val jsonld = JsonLdAssembly( + nxv + "id", + source, + compacted, + ExpandedJsonLd.empty, + Graph.empty(id), + remoteContexts + ) + + private val current = DetectChange.Current(Set.empty, source, compacted, remoteContexts) + + private val detectChange = DetectChange(enabled = true) + + test("No change is detected") { + detectChange(jsonld, current).assertEquals(false) + } + + test("A change is detected if resource types are different") { + val otherTypes: Set[Iri] = Set(nxv + "another-type") + detectChange(jsonld, current.copy(types = otherTypes)).assertEquals(true) + } + + test("A change is detected if the remote contexts are different") { + val otherRemoteContexts: Set[RemoteContextRef] = Set(StaticContextRef(nxv + "another-static")) + detectChange(jsonld, current.copy(remoteContexts = otherRemoteContexts)).assertEquals(true) + } + + test("A change is detected if the local contexts are different") { + val otherLocalContext = compacted.copy(ctx = ContextValue(nxv + "another-context")) + detectChange(jsonld, current.copy(compacted = otherLocalContext)).assertEquals(true) + } + + test("A change is detected if the source differs") { + val otherSource = Json.obj("source" := "another-value") + detectChange(jsonld, current.copy(source = otherSource)).assertEquals(true) + } + +} diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImplSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImplSpec.scala index e8221db9f4..2aa765eb45 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImplSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesImplSpec.scala @@ -76,7 +76,7 @@ class ResourcesImplSpec private val resourceResolution: ResourceResolution[Schema] = ResourceResolutionGen.singleInProject(projectRef, fetchSchema) - private val fetchContext = FetchContextDummy( + private val fetchContext = FetchContextDummy( Map( project.ref -> project.context.copy(apiMappings = allApiMappings), projectDeprecated.ref -> projectDeprecated.context @@ -84,7 +84,8 @@ class ResourcesImplSpec Set(projectDeprecated.ref), ProjectContextRejection ) - private val config = ResourcesConfig(eventLogConfig, DecodingOption.Strict) + private val config = ResourcesConfig(eventLogConfig, DecodingOption.Strict, skipUpdateNoChange = true) + private val detectChanges = DetectChange(enabled = config.skipUpdateNoChange) private val resolverContextResolution: ResolverContextResolution = new ResolverContextResolution( res, @@ -93,6 +94,7 @@ class ResourcesImplSpec private lazy val resources: Resources = ResourcesImpl( ValidateResource(resourceResolution), + detectChanges, fetchContext, resolverContextResolution, config, @@ -130,6 +132,7 @@ class ResourcesImplSpec ResourceGen.resourceFor(res, types = types, subject = subject) "creating a resource" should { + "succeed with the id present on the payload" in { forAll(List(myId -> resourceSchema, myId2 -> Latest(schema1.id))) { case (id, schemaRef) => val sourceWithId = source deepMerge json"""{"@id": "$id"}""" @@ -206,7 +209,7 @@ class ResourcesImplSpec } "succeed with payload without @context" in { - val payload = json"""{"name": "Alice"}""" + val payload = json"""{ "@type": "Person", "name": "Alice"}""" val payloadWithCtx = payload.addContext(json"""{"@context": {"@vocab": "${nxv.base}","@base": "${nxv.base}"}}""") val schemaRev = Revision(resourceSchema.iri, 1) @@ -214,7 +217,7 @@ class ResourcesImplSpec ResourceGen.resource(myId7, projectRef, payloadWithCtx, schemaRev).copy(source = payload) resources.create(myId7, projectRef, schemas.resources, payload, None).accepted shouldEqual - ResourceGen.resourceFor(expectedData, subject = subject) + ResourceGen.resourceFor(expectedData, Set(nxv + "Person"), subject = subject) } "succeed with the id present on the payload and pointing to another resource in its context" in { diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesSpec.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesSpec.scala index 167b1b937f..3df0f87edf 100644 --- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesSpec.scala +++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ResourcesSpec.scala @@ -34,12 +34,13 @@ class ResourcesSpec extends CatsEffectSpec with CirceLiteral with ValidateResour val caller = Caller(subject, Set.empty) val tag = UserTag.unsafe("mytag") - val jsonld = JsonLdAssembly(myId, source, compacted, expanded, graph, remoteContexts) + val detectChange = DetectChange(enabled = true) + val jsonld = JsonLdAssembly(myId, source, compacted, expanded, graph, remoteContexts) val schema1 = nxv + "myschema" val eval: (Option[ResourceState], ResourceCommand) => IO[ResourceEvent] = - evaluate(alwaysValidate, clock) + evaluate(alwaysValidate, detectChange, clock) "evaluating an incoming command" should { "create a new event from a CreateResource command" in { @@ -111,7 +112,6 @@ class ResourcesSpec extends CatsEffectSpec with CirceLiteral with ValidateResour } "create a tag event from a UpdateResource command when no changes are detected and a tag is provided" in { - println("Tag from update") val schema = Latest(schemas.resources) val current = ResourceGen.currentState(projectRef, jsonld, schema) eval( diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala index 3794f85436..25ee77f3dd 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Optics.scala @@ -116,6 +116,7 @@ object Optics { val `@id` = root.`@id`.string val `@type` = root.`@type`.string + val context = root.`@context`.json val _uuid = root._uuid.string val _rev = root._rev.int 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 8b4bd00cca..485f75688f 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 @@ -17,6 +17,7 @@ import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.Resources import ch.epfl.bluebrain.nexus.tests.resources.SimpleResource import ch.epfl.bluebrain.nexus.tests.{BaseIntegrationSpec, Optics, SchemaPayload} import io.circe.optics.JsonPath.root +import io.circe.syntax.{EncoderOps, KeyOps} import io.circe.{Json, JsonObject} import monocle.Optional import org.scalatest.Assertion @@ -30,10 +31,8 @@ class ResourcesSpec extends BaseIntegrationSpec { private val orgId = genId() private val projId1 = genId() private val projId2 = genId() - private val projId3 = genId() private val project1 = s"$orgId/$projId1" private val project2 = s"$orgId/$projId2" - private val project3 = s"$orgId/$projId3" private val IdLens: Optional[Json, String] = root.`@id`.string private val TypeLens: Optional[Json, String] = root.`@type`.string @@ -683,38 +682,65 @@ class ResourcesSpec extends BaseIntegrationSpec { "refreshing a resource" should { - val Base = "http://my-original-base.com/" - val NewBase = "http://my-new-base.com/" + val projId3 = genId() + val project3 = s"$orgId/$projId3" - val ResourceId = "resource-with-type" - val FullResourceId = s"$Base/$ResourceId" - val idEncoded = UrlUtils.encode(FullResourceId) + val originalBase = "http://my-original-base.com/" + val newBase = "http://my-new-base.com/" + val vocab = s"${config.deltaUri}/vocabs/$project3/" - val ResourceType = "my-type" - val FullResourceType = s"$Base$ResourceType" - val NewFullResourceType = s"$NewBase$ResourceType" + val noContextId = s"${originalBase}no-context" + val noContextIdEncoded = UrlUtils.encode(noContextId) + + val noBaseId = s"${originalBase}no-base" + val noBaseIdEncoded = UrlUtils.encode(noBaseId) + + val tpe = "my-type" + val expandedType = s"$originalBase$tpe" + val newExpandedType = s"$newBase$tpe" + + def contextWithBase(base: String) = + json""" + [ + "https://bluebrain.github.io/nexus/contexts/metadata.json", + { "@base" : "$base", "@vocab" : "$vocab" } + ]""" "create a project" in { - val payload = kgDsl.projectJsonWithCustomBase(name = project3, base = Base).accepted + val payload = kgDsl.projectJsonWithCustomBase(name = project3, base = originalBase).accepted adminDsl.createProject(orgId, projId3, payload, Rick) } - "create resource using the created project" in { - val payload = - jsonContentOf("kg/resources/simple-resource-with-type.json", "id" -> FullResourceId, "type" -> ResourceType) + "create resource without a context" in { + val payload = json"""{ "@id": "$noContextId", "@type": "$tpe" }""" deltaClient.post[Json](s"/resources/$project3/", payload, Rick) { expectCreated } } - "type should be expanded" in { - deltaClient.get[Json](s"/resources/$project3/_/$idEncoded", Rick) { (json, response) => + "fetch the resource with a context injected from the project configuration" in { + deltaClient.get[Json](s"/resources/$project3/_/$noContextIdEncoded", Rick) { (json, response) => + response.status shouldEqual StatusCodes.OK + json should have(`@type`(tpe)) + Optics.context.getOption(json).value shouldEqual contextWithBase(originalBase) + } + } + + "create resource with a context without a base" in { + val payload = json"""{ "@context": { "name": "https://schema.org/name"}, "@id": "$noBaseId", "@type": "$tpe" }""" + deltaClient.post[Json](s"/resources/$project3/", payload, Rick) { + expectCreated + } + } + + "fetch the resource with without a define base" in { + deltaClient.get[Json](s"/resources/$project3/_/$noBaseIdEncoded", Rick) { (json, response) => response.status shouldEqual StatusCodes.OK - json should have(`@type`(FullResourceType)) + json should have(`@type`(expandedType)) } } "update a project" in { for { - project <- kgDsl.projectJsonWithCustomBase(name = project3, base = NewBase) + project <- kgDsl.projectJsonWithCustomBase(name = project3, base = newBase) _ <- adminDsl.updateProject( orgId, @@ -726,16 +752,41 @@ class ResourcesSpec extends BaseIntegrationSpec { } yield succeed } - "do a refresh" in { - deltaClient - .put[Json](s"/resources/$project3/_/$idEncoded/refresh?rev=1", Json.Null, Rick) { expectOk } + "fetch the resource with a context injected from the new project configuration after a refresh" in { + for { + _ <- deltaClient.put[Json](s"/resources/$project3/_/$noContextIdEncoded/refresh?rev=1", Json.Null, Rick) { + expectOk + } + _ <- deltaClient.get[Json](s"/resources/$project3/_/$noContextIdEncoded", Rick) { (json, response) => + response.status shouldEqual StatusCodes.OK + json should have(`@type`(tpe)) + Optics.context.getOption(json).value shouldEqual contextWithBase(newBase) + Optics._rev.getOption(json).value shouldEqual 2 + } + _ <- deltaClient.put[Json](s"/resources/$project3/_/$noContextIdEncoded/refresh?rev=2", Json.Null, Rick) { + (json, response) => + response.status shouldEqual StatusCodes.OK + Optics._rev.getOption(json).value shouldEqual 2 + } + } yield succeed } - "type should be updated" in { - deltaClient.get[Json](s"/resources/$project3/_/$idEncoded", Rick) { (json, response) => - response.status shouldEqual StatusCodes.OK - json should have(`@type`(NewFullResourceType)) - } + "fetch the resource with a defined base from the new project configuration after a refresh" in { + for { + _ <- deltaClient.put[Json](s"/resources/$project3/_/$noBaseIdEncoded/refresh?rev=1", Json.Null, Rick) { + expectOk + } + _ <- deltaClient.get[Json](s"/resources/$project3/_/$noBaseIdEncoded", Rick) { (json, response) => + response.status shouldEqual StatusCodes.OK + json should have(`@type`(newExpandedType)) + Optics._rev.getOption(json).value shouldEqual 2 + } + _ <- deltaClient.put[Json](s"/resources/$project3/_/$noBaseIdEncoded/refresh?rev=2", Json.Null, Rick) { + (json, response) => + response.status shouldEqual StatusCodes.OK + Optics._rev.getOption(json).value shouldEqual 2 + } + } yield succeed } } @@ -777,6 +828,34 @@ class ResourcesSpec extends BaseIntegrationSpec { } + "checking for update changes for a large resource" should { + "succeed" in { + val id = "large" + val tpe = "Random" + val largeRandomPayload = { + val entry = Json.obj( + "array" := (1 to 100).toList, + "string" := "some-value" + ) + (1 to 500).foldLeft(JsonObject("@type" := tpe)) { case (acc, index) => + acc.add(s"prop$index", entry) + } + }.asJson + + for { + _ <- deltaClient.put[Json](s"/resources/$project2/_/test-resource:$id", largeRandomPayload, Rick) { + expectCreated + } + _ <- deltaClient + .put[Json](s"/resources/$project2/_/test-resource:$id?rev=1", largeRandomPayload, Rick) { + (json, response) => + response.status shouldEqual StatusCodes.OK + Optics._rev.getOption(json).value shouldEqual 1 + } + } yield succeed + } + } + private def givenAResourceWithSchemaAndTag(projectRef: String, schema: Option[String], tag: Option[String])( assertion: String => Assertion ): Assertion = {