Repository: BlueBrain/nexus (scala)

Issue:
Improve error when no node can be targeted by a schema
**Motivation**
The api returns the same error if a resource does not respect the constraints of a schema and if a schema can't target any node in the given resource

**Acceptance criteria**
1. A dedicated error is introduced when a schema can't target a node in a resource and gives some hints on what can be the reason

Pull request:
Fixes #4919

Patch:
diff --git a/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReport.scala b/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReport.scala
index 9f4c75e30d..0400b12ef2 100644
--- a/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReport.scala
+++ b/delta/rdf/src/main/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReport.scala
@@ -24,14 +24,9 @@ import org.apache.jena.rdf.model.Resource
   */
 final case class ValidationReport private (conforms: Boolean, targetedNodes: Int, json: Json) {
 
-  /**
-    * @param ignoreTargetedNodes
-    *   flag to decide whether or not ''targetedNodes'' should be ignored from the validation logic
-    * @return
-    *   true if the validation report has been successful or false otherwise
-    */
-  def isValid(ignoreTargetedNodes: Boolean = false): Boolean =
-    (ignoreTargetedNodes && conforms) || (!ignoreTargetedNodes && targetedNodes > 0 && conforms)
+  def withTargetedNodes: Boolean = targetedNodes > 0
+
+  def conformsWithTargetedNodes: Boolean = conforms && withTargetedNodes
 }
 
 object ValidationReport {
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResource.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResource.scala
index a1ba842ebd..9ce7abff6e 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResource.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResource.scala
@@ -13,7 +13,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resolvers.ResolverResolution.ResourceRe
 import ch.epfl.bluebrain.nexus.delta.sdk.resources.Resources.kamonComponent
 import ch.epfl.bluebrain.nexus.delta.sdk.resources.SchemaClaim.SubmitOnDefinedSchema
 import ch.epfl.bluebrain.nexus.delta.sdk.resources.ValidationResult._
-import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.{InvalidResource, InvalidSchemaRejection, ReservedResourceId, ResourceShaclEngineRejection, SchemaIsDeprecated}
+import ch.epfl.bluebrain.nexus.delta.sdk.resources.model.ResourceRejection.{InvalidResource, InvalidSchemaRejection, NoTargetedNode, ReservedResourceId, ResourceShaclEngineRejection, SchemaIsDeprecated}
 import ch.epfl.bluebrain.nexus.delta.sdk.schemas.model.Schema
 import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef}
 
@@ -70,7 +70,8 @@ object ValidateResource {
         val schemaRef = ResourceRef.Revision(schema.id, schema.rev)
         for {
           report <- shaclValidate(jsonld, schemaRef, schema)
-          _      <- IO.raiseWhen(!report.isValid())(InvalidResource(jsonld.id, schemaRef, report, jsonld.expanded))
+          _      <- IO.raiseUnless(report.conforms)(InvalidResource(jsonld.id, schemaRef, report, jsonld.expanded))
+          _      <- IO.raiseUnless(report.withTargetedNodes)(NoTargetedNode(jsonld.id, schemaRef, jsonld.expanded))
         } yield Validated(schema.value.project, ResourceRef.Revision(schema.id, schema.rev), report)
       }
 
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceRejection.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceRejection.scala
index ffd7974560..04d6e7736c 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceRejection.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/model/ResourceRejection.scala
@@ -106,7 +106,7 @@ object ResourceRejection {
 
   /**
     * Rejection returned when attempting to create/update a resource where the payload does not satisfy the SHACL schema
-    * constrains.
+    * constraints.
     *
     * @param id
     *   the resource identifier
@@ -120,6 +120,27 @@ object ResourceRejection {
         s"Resource '$id' failed to validate against the constraints defined in schema '$schema'"
       )
 
+  /**
+    * Rejection returned when attempting to create/update a resource when no target could be produced for the resource
+    * for the given schema.
+    *
+    * @param id
+    *   the resource identifier
+    * @param schema
+    *   the schema for which validation failed
+    */
+  final case class NoTargetedNode(id: Iri, schema: ResourceRef, expanded: ExpandedJsonLd)
+      extends ResourceRejection(
+        s"No target node could be produced for resource '$id' with schema '$schema'."
+      ) {
+    val details: String =
+      s"""
+        |In the case of class-based targets, make sure that it defines one of the following types:
+        |${expanded.getTypes.getOrElse(Set.empty).mkString(",")}
+        |See https://www.w3.org/TR/shacl/#targets for more details
+        |""".stripMargin
+  }
+
   /**
     * Rejection returned when attempting to resolve ''schemaRef'' using resolvers on project ''projectRef''
     */
@@ -218,6 +239,7 @@ object ResourceRejection {
           obj.addContext(contexts.shacl).add("details", report.json).add("expanded", expanded.json)
         case InvalidSchemaRejection(_, _, report)      =>
           obj.addContext(contexts.resolvers).add("report", report.asJson)
+        case n: NoTargetedNode                         => obj.add("details", n.details.asJson)
         case IncorrectRev(provided, expected)          => obj.add("provided", provided.asJson).add("expected", expected.asJson)
         case _                                         => obj
       }
diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/schemas/Schemas.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/schemas/Schemas.scala
index cecea5edee..b59c385bdd 100644
--- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/schemas/Schemas.scala
+++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/schemas/Schemas.scala
@@ -272,7 +272,7 @@ object Schemas {
       for {
         _      <- IO.raiseWhen(id.startsWith(schemas.base))(ReservedSchemaId(id))
         report <- shaclValidation(id, expanded)
-        result <- IO.raiseWhen(!report.isValid())(InvalidSchema(id, report))
+        result <- IO.raiseWhen(!report.conformsWithTargetedNodes)(InvalidSchema(id, report))
       } yield result
 
     def create(c: CreateSchema) =


Test patch:
diff --git a/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidateShaclSuite.scala b/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidateShaclSuite.scala
index 71cb34d73b..481abeda24 100644
--- a/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidateShaclSuite.scala
+++ b/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidateShaclSuite.scala
@@ -27,7 +27,7 @@ class ValidateShaclSuite extends NexusSuite {
   private def toGraph(json: Json) = ExpandedJsonLd(json).map(_.toGraph).rethrow
 
   test("Validate data from schema model") {
-    shaclValidation(dataGraph, schemaGraph, reportDetails = true).assert(_.isValid())
+    shaclValidation(dataGraph, schemaGraph, reportDetails = true).assert(_.conformsWithTargetedNodes)
   }
 
   test("Fail validating data if not matching nodes") {
@@ -35,7 +35,7 @@ class ValidateShaclSuite extends NexusSuite {
     for {
       resourceGraph <- toGraph(dataChangedType)
       _             <- shaclValidation(resourceGraph, schemaGraph, reportDetails = true).assert { report =>
-                         !report.isValid() && report.targetedNodes == 0
+                         !report.conformsWithTargetedNodes && report.targetedNodes == 0
                        }
     } yield ()
   }
@@ -51,14 +51,14 @@ class ValidateShaclSuite extends NexusSuite {
   }
 
   test("Validate shapes") {
-    shaclValidation(schemaGraph, reportDetails = true).assert(_.isValid())
+    shaclValidation(schemaGraph, reportDetails = true).assert(_.conformsWithTargetedNodes)
   }
 
   test("Fail validating shapes if unexpected field value") {
     val wrongSchema = schema.replace("minCount" -> 1, "wrong")
     for {
       wrongGraph <- toGraph(wrongSchema)
-      _          <- shaclValidation(wrongGraph, reportDetails = true).assert(_.isValid() == false)
+      _          <- shaclValidation(wrongGraph, reportDetails = true).assert(_.conformsWithTargetedNodes == false)
     } yield ()
   }
 
diff --git a/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReportSpec.scala b/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReportSpec.scala
index f9f55bec15..2886f33971 100644
--- a/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReportSpec.scala
+++ b/delta/rdf/src/test/scala/ch/epfl/bluebrain/nexus/delta/rdf/shacl/ValidationReportSpec.scala
@@ -39,7 +39,7 @@ class ValidationReportSpec extends CatsEffectSpec {
       val report = ValidationReport(resource(failed)).accepted
       report.conforms shouldEqual false
       report.targetedNodes shouldEqual 1
-      report.isValid() shouldEqual false
+      report.conformsWithTargetedNodes shouldEqual false
       val array  = report.json.hcursor.downField("result").downField("detail").focus.flatMap(_.asArray).value
       array.map(_.hcursor.get[String]("resultMessage").rightValue).sorted shouldEqual Vector(
         "Focus node has 2^^http://www.w3.org/2001/XMLSchema#integer of the shapes from the 'exactly one' list",
diff --git a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResourceSuite.scala b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResourceSuite.scala
index 9911a48663..d1903a300c 100644
--- a/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResourceSuite.scala
+++ b/delta/sdk/src/test/scala/ch/epfl/bluebrain/nexus/delta/sdk/resources/ValidateResourceSuite.scala
@@ -66,19 +66,20 @@ class ValidateResourceSuite extends NexusSuite {
   private val schemaResolution: ResourceResolution[Schema]             =
     ResourceResolutionGen.singleInProject(project, fetchSchema)
 
-  private def sourceWithId(id: Iri)                                   =
-    loader.jsonContentOf("resources/resource.json", "id" -> id)
+  private def sourceWithId(id: Iri, patch: Json => Json) =
+    loader.jsonContentOf("resources/resource.json", "id" -> id).map(patch)
 
-  private def jsonLdWithId(id: Iri, source: Json): IO[JsonLdAssembly] =
+  private def jsonLdWithId(id: Iri, patchSource: Json => Json): IO[JsonLdAssembly] = {
     for {
-      expanded <- ExpandedJsonLd(source)
-      graph    <- IO.fromEither(expanded.toGraph)
-    } yield JsonLdAssembly(id, source, CompactedJsonLd.empty, expanded, graph, Set.empty)
-
-  private def jsonLdWithId(id: Iri): IO[JsonLdAssembly] = sourceWithId(id).flatMap { source =>
-    jsonLdWithId(id, source)
+      patchedSource <- sourceWithId(id, patchSource)
+      expanded      <- ExpandedJsonLd(patchedSource)
+      graph         <- IO.fromEither(expanded.toGraph)
+    } yield JsonLdAssembly(id, patchedSource, CompactedJsonLd.empty, expanded, graph, Set.empty)
   }
 
+  private val validResourceId = nxv + "valid"
+  private val validResource   = jsonLdWithId(validResourceId, identity)
+
   private val validateResource = ValidateResource(schemaResolution, ValidateShacl(rcr).accepted)
 
   private def assertResult(result: ValidationResult, expectedProject: ProjectRef, expectedSchema: ResourceRef.Revision)(
@@ -89,9 +90,8 @@ class ValidateResourceSuite extends NexusSuite {
   }
 
   test("Validate a resource with the appropriate schema") {
-    val id = nxv + "valid"
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- validResource
       schemaClaim = SchemaClaim.onCreate(project, schemaRef, caller)
       result     <- validateResource(jsonLd, schemaClaim, enforceSchema = false)
     } yield {
@@ -100,9 +100,8 @@ class ValidateResourceSuite extends NexusSuite {
   }
 
   test("Validate a resource with no schema and no schema enforcement is enabled") {
-    val id = nxv + "valid"
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- validResource
       schemaClaim = SchemaClaim.onCreate(project, unconstrained, caller)
       result     <- validateResource(jsonLd, schemaClaim, enforceSchema = false)
     } yield {
@@ -113,7 +112,7 @@ class ValidateResourceSuite extends NexusSuite {
   test("Reject a resource when the id starts with a reserved prefix") {
     val id = contexts.base / "fail"
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- jsonLdWithId(id, identity)
       schemaClaim = SchemaClaim.onCreate(project, schemaRef, caller)
       _          <- validateResource(jsonLd, schemaClaim, enforceSchema = false)
                       .interceptEquals(ReservedResourceId(id))
@@ -121,16 +120,14 @@ class ValidateResourceSuite extends NexusSuite {
   }
 
   test("Reject a resource with no schema and schema enforcement is enabled") {
-    val id = nxv + "valid"
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- validResource
       schemaClaim = SchemaClaim.onCreate(project, unconstrained, caller)
       _          <- validateResource(jsonLd, schemaClaim, enforceSchema = true).interceptEquals(SchemaIsMandatory(project))
     } yield ()
   }
 
   test("Reject a resource when schema is not found") {
-    val id            = nxv + "valid"
     val unknownSchema = Latest(nxv + "not-found")
     val expectedError = InvalidSchemaRejection(
       unknownSchema,
@@ -143,16 +140,15 @@ class ValidateResourceSuite extends NexusSuite {
       )
     )
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- validResource
       schemaClaim = SchemaClaim.onCreate(project, unknownSchema, caller)
       _          <- validateResource(jsonLd, schemaClaim, enforceSchema = true).interceptEquals(expectedError)
     } yield ()
   }
 
   test("Reject a resource when the resolved schema is deprecated") {
-    val id = nxv + "valid"
     for {
-      jsonLd     <- jsonLdWithId(id)
+      jsonLd     <- validResource
       schemaClaim = SchemaClaim.onCreate(project, deprecatedSchemaRef, caller)
       _          <- validateResource(jsonLd, schemaClaim, enforceSchema = true).interceptEquals(
                       SchemaIsDeprecated(deprecatedSchemaId)
@@ -160,14 +156,20 @@ class ValidateResourceSuite extends NexusSuite {
     } yield ()
   }
 
-  test("Reject a resource when it can't be validated by the default schema") {
-    val id = nxv + "valid"
+  test("Reject a resource when it can't be validated by the provided schema") {
     for {
-      source     <- sourceWithId(id).map(_.removeKeys("name"))
-      jsonLd     <- jsonLdWithId(id, source)
+      jsonLd     <- jsonLdWithId(validResourceId, _.removeKeys("name"))
       schemaClaim = SchemaClaim.onCreate(project, schemaRef, caller)
       _          <- validateResource(jsonLd, schemaClaim, enforceSchema = true).intercept[InvalidResource]
     } yield ()
   }
 
+  test("Reject a resource when it can't be targeted by the provided schema") {
+    for {
+      jsonLd     <- jsonLdWithId(validResourceId, _.replaceKeyWithValue("@type", "nxv:Another"))
+      schemaClaim = SchemaClaim.onCreate(project, schemaRef, caller)
+      _          <- validateResource(jsonLd, schemaClaim, enforceSchema = true).intercept[NoTargetedNode]
+    } yield ()
+  }
+
 }
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 84576e7260..8543012e09 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
@@ -107,7 +107,7 @@ class ResourcesSpec extends BaseIntegrationSpec {
         _ <- deltaClient.put[Json](s"/resources/$project1/test-schema/test-resource:1", Json.obj(), Rick) {
                (json, response) =>
                  response.status shouldEqual StatusCodes.BadRequest
-                 json should have(`@type`("InvalidResource"))
+                 json should have(`@type`("NoTargetedNode"))
              }
       } yield succeed
     }
diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/resources/ResourcesTrialSpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/resources/ResourcesTrialSpec.scala
index 977cda2524..6b9a4ae217 100644
--- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/resources/ResourcesTrialSpec.scala
+++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/resources/ResourcesTrialSpec.scala
@@ -104,7 +104,7 @@ class ResourcesTrialSpec extends BaseIntegrationSpec {
         response.status shouldEqual StatusCodes.OK
         resultId(json) shouldBe empty
         schema(json).value shouldBe newSchemaId
-        errorType(json).value shouldEqual "InvalidResource"
+        errorType(json).value shouldEqual "NoTargetedNode"
       }
     }
   }