diff --git a/delta/app/src/main/resources/app.conf b/delta/app/src/main/resources/app.conf index 4033a288d5..ea90dd25cf 100644 --- a/delta/app/src/main/resources/app.conf +++ b/delta/app/src/main/resources/app.conf @@ -125,6 +125,7 @@ app { "version/read", "quotas/read", "supervision/read", + "typehierarchy/write" "export/run" ] diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutes.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutes.scala new file mode 100644 index 0000000000..0f3190f6c6 --- /dev/null +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutes.scala @@ -0,0 +1,65 @@ +package ch.epfl.bluebrain.nexus.delta.routes + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Route +import cats.implicits.catsSyntaxApplicativeError +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution +import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress +import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling +import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives +import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ +import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities +import ch.epfl.bluebrain.nexus.delta.sdk.implicits._ +import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.typehierarchy +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.{TypeHierarchy => TypeHierarchyModel} +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model.{TypeHierarchy, TypeHierarchyRejection} + +final class TypeHierarchyRoutes( + typeHierarchy: TypeHierarchyModel, + identities: Identities, + aclCheck: AclCheck +)(implicit + baseUri: BaseUri, + cr: RemoteContextResolution, + ordering: JsonKeyOrdering +) extends AuthDirectives(identities, aclCheck) + with CirceUnmarshalling { + + def routes: Route = + baseUriPrefix(baseUri.prefix) { + extractCaller { implicit caller => + pathPrefix("type-hierarchy") { + concat( + // Fetch using the revision + (get & parameter("rev".as[Int]) & pathEndOrSingleSlash) { rev => + emit(typeHierarchy.fetch(rev).attemptNarrow[TypeHierarchyRejection]) + }, + // Fetch the type hierarchy + (get & pathEndOrSingleSlash) { + emit(typeHierarchy.fetch.attemptNarrow[TypeHierarchyRejection]) + }, + // Create the type hierarchy + (post & pathEndOrSingleSlash) { + entity(as[TypeHierarchy]) { payload => + authorizeFor(AclAddress.Root, typehierarchy.write).apply { + emit(StatusCodes.Created, typeHierarchy.create(payload.mapping).attemptNarrow[TypeHierarchyRejection]) + } + } + }, + // Update the type hierarchy + (put & parameter("rev".as[Int]) & pathEndOrSingleSlash) { rev => + entity(as[TypeHierarchy]) { payload => + authorizeFor(AclAddress.Root, typehierarchy.write).apply { + emit(typeHierarchy.update(payload.mapping, rev).attemptNarrow[TypeHierarchyRejection]) + } + } + } + ) + } + } + } + +} 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 e1920eb4f3..1307513634 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 @@ -190,6 +190,7 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class include(ExportModule) include(StreamModule) include(SupervisionModule) + include(TypeHierarchyModule) } diff --git a/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/TypeHierarchyModule.scala b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/TypeHierarchyModule.scala new file mode 100644 index 0000000000..5695ff8078 --- /dev/null +++ b/delta/app/src/main/scala/ch/epfl/bluebrain/nexus/delta/wiring/TypeHierarchyModule.scala @@ -0,0 +1,54 @@ +package ch.epfl.bluebrain.nexus.delta.wiring + +import cats.effect.{Clock, IO} +import ch.epfl.bluebrain.nexus.delta.Main.pluginsMaxPriority +import ch.epfl.bluebrain.nexus.delta.config.AppConfig +import ch.epfl.bluebrain.nexus.delta.kernel.utils.ClasspathResourceLoader +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution} +import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering +import ch.epfl.bluebrain.nexus.delta.routes.TypeHierarchyRoutes +import ch.epfl.bluebrain.nexus.delta.sdk.PriorityRoute +import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck +import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.TypeHierarchy +import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors +import izumi.distage.model.definition.{Id, ModuleDef} + +object TypeHierarchyModule extends ModuleDef { + + implicit private val loader: ClasspathResourceLoader = ClasspathResourceLoader.withContext(getClass) + + make[TypeHierarchy].from { (xas: Transactors, config: AppConfig, clock: Clock[IO]) => + TypeHierarchy(xas, config.typeHierarchy, clock) + } + + make[TypeHierarchyRoutes].from { + ( + identities: Identities, + typeHierarchy: TypeHierarchy, + aclCheck: AclCheck, + config: AppConfig, + cr: RemoteContextResolution @Id("aggregate"), + ordering: JsonKeyOrdering + ) => + new TypeHierarchyRoutes( + typeHierarchy, + identities, + aclCheck + )(config.http.baseUri, cr, ordering) + } + + many[RemoteContextResolution].addEffect( + for { + typeHierarchyCtx <- ContextValue.fromFile("contexts/type-hierarchy.json") + } yield RemoteContextResolution.fixed( + contexts.typeHierarchy -> typeHierarchyCtx + ) + ) + + many[PriorityRoute].add { (route: TypeHierarchyRoutes) => + PriorityRoute(pluginsMaxPriority + 14, route.routes, requiresStrictEntity = true) + } + +} diff --git a/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutesSpec.scala b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutesSpec.scala new file mode 100644 index 0000000000..261527a244 --- /dev/null +++ b/delta/app/src/test/scala/ch/epfl/bluebrain/nexus/delta/routes/TypeHierarchyRoutesSpec.scala @@ -0,0 +1,134 @@ +package ch.epfl.bluebrain.nexus.delta.routes + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Route +import cats.effect.{IO, Ref} +import ch.epfl.bluebrain.nexus.delta.rdf.syntax.iriStringContextSyntax +import ch.epfl.bluebrain.nexus.delta.sdk.TypeHierarchyResource +import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress +import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.typehierarchy +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.TypeHierarchy +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model.TypeHierarchy.TypeHierarchyMapping +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model.TypeHierarchyRejection.TypeHierarchyDoesNotExist +import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model.{TypeHierarchy => TypeHierarchyModel, TypeHierarchyState} +import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User +import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Identity, Label} +import io.circe.syntax.EncoderOps +import org.scalatest.{Assertion, BeforeAndAfterEach} + +import java.time.Instant + +class TypeHierarchyRoutesSpec extends BaseRouteSpec with BeforeAndAfterEach { + + private val typeHierarchyWriter = User("superUser", Label.unsafe(genString())) + private val (aclCheck, identities) = usersFixture( + (typeHierarchyWriter, AclAddress.Root, Set(typehierarchy.write)) + ) + + private val typeHierarchyRef = Ref.unsafe[IO, Option[TypeHierarchyResource]](None) + + private val typeHierarchy = new TypeHierarchy { + override def create( + mapping: TypeHierarchyMapping + )(implicit subject: Identity.Subject): IO[TypeHierarchyResource] = { + typeHierarchyRef.set(Some(typeHierarchyResource(rev = 1))) >> + IO.pure(typeHierarchyResource(rev = 1)) + } + + override def update(mapping: TypeHierarchyMapping, rev: Int)(implicit + subject: Identity.Subject + ): IO[TypeHierarchyResource] = + typeHierarchyRef + .getAndSet(Some(typeHierarchyResource(rev = rev + 1))) + .flatMap(IO.fromOption(_)(TypeHierarchyDoesNotExist)) + + override def fetch: IO[TypeHierarchyResource] = + typeHierarchyRef.get.flatMap(IO.fromOption(_)(TypeHierarchyDoesNotExist)) + + override def fetch(rev: Int): IO[TypeHierarchyResource] = + typeHierarchyRef.get.flatMap(IO.fromOption(_)(TypeHierarchyDoesNotExist)) + } + + private val routes = Route.seal( + new TypeHierarchyRoutes( + typeHierarchy, + identities, + aclCheck + ).routes + ) + + private val mapping = Map( + iri"https://schema.org/Movie" -> Set(iri"https://schema.org/CreativeWork", iri"https://schema.org/Thing") + ) + private val jsonMapping = TypeHierarchyModel(mapping).asJson + + override def beforeEach(): Unit = { + super.beforeAll() + typeHierarchyRef.set(None).accepted + } + + "The TypeHierarchyRoutes" should { + "fail to create the type hierarchy without permissions" in { + Post("/v1/type-hierarchy", jsonMapping.toEntity) ~> routes ~> check { + response.shouldBeForbidden + typeHierarchyRef.get.accepted shouldEqual None + } + } + + "succeed to create the type hierarchy with write permissions" in { + Post("/v1/type-hierarchy", jsonMapping.toEntity) ~> as(typeHierarchyWriter) ~> routes ~> check { + status shouldEqual StatusCodes.Created + typeHierarchyRef.get.accepted shouldEqual Some(typeHierarchyResource(rev = 1)) + } + } + + "return the type hierarchy anonymously" in { + givenATypeHierarchyExists { + Get("/v1/type-hierarchy") ~> routes ~> check { + status shouldEqual StatusCodes.OK + typeHierarchyRef.get.accepted shouldEqual Some(typeHierarchyResource(rev = 1)) + } + } + } + + "fail to update the type hierarchy without permissions" in { + givenATypeHierarchyExists { + Put("/v1/type-hierarchy?rev=1", jsonMapping.toEntity) ~> routes ~> check { + response.shouldBeForbidden + typeHierarchyRef.get.accepted shouldEqual Some(typeHierarchyResource(rev = 1)) + } + } + } + + "succeed to update the type hierarchy with write permissions" in { + givenATypeHierarchyExists { + Put("/v1/type-hierarchy?rev=1", jsonMapping.toEntity) ~> as(typeHierarchyWriter) ~> routes ~> check { + status shouldEqual StatusCodes.OK + typeHierarchyRef.get.accepted shouldEqual Some(typeHierarchyResource(rev = 2)) + } + } + } + + } + + def givenATypeHierarchyExists(test: => Assertion): Assertion = + Post("/v1/type-hierarchy", jsonMapping.toEntity) ~> as(typeHierarchyWriter) ~> routes ~> check { + status shouldEqual StatusCodes.Created + test + } + + private def typeHierarchyResource(rev: Int) = + TypeHierarchyState( + Map( + iri"https://schema.org/Movie" -> Set(iri"https://schema.org/CreativeWork", iri"https://schema.org/Thing") + ), + rev = rev, + deprecated = false, + createdAt = Instant.EPOCH, + createdBy = typeHierarchyWriter, + updatedAt = Instant.EPOCH, + updatedBy = typeHierarchyWriter + ).toResource + +} 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 f8d69b67c4..1cfc3a60e2 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 @@ -244,6 +244,7 @@ object Vocabulary { val supervision = contexts + "supervision.json" val suites = contexts + "suites.json" val tags = contexts + "tags.json" + val typeHierarchy = contexts + "type-hierarchy.json" val validation = contexts + "validation.json" val version = contexts + "version.json" diff --git a/delta/sdk/src/main/resources/contexts/type-hierarchy.json b/delta/sdk/src/main/resources/contexts/type-hierarchy.json new file mode 100644 index 0000000000..d76303cf18 --- /dev/null +++ b/delta/sdk/src/main/resources/contexts/type-hierarchy.json @@ -0,0 +1,7 @@ +{ + "@context": { + "@vocab": "https://bluebrain.github.io/nexus/vocabulary/", + "mapping": "https://bluebrain.github.io/nexus/vocabulary/mapping" + }, + "@id": "https://bluebrain.github.io/nexus/contexts/type-hierarchy.json" +} \ No newline at end of file diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala index e25717590c..ea31d277de 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/permissions/Permissions.scala @@ -218,10 +218,20 @@ object Permissions { final val read: Permission = Permission.unsafe("quotas/read") } + /** + * Supervision permissions. + */ object supervision { final val read: Permission = Permission.unsafe("supervision/read") } + /** + * Type hierarchy permissions. + */ + object typehierarchy { + final val write: Permission = Permission.unsafe("typehierarchy/write") + } + private[delta] def next( minimum: Set[Permission] )(state: PermissionsState, event: PermissionsEvent): PermissionsState = { diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchy.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchy.scala index d0959a6477..d60050f092 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchy.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchy.scala @@ -1,10 +1,13 @@ package ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model import ch.epfl.bluebrain.nexus.delta.rdf.IriOrBNode.Iri +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder import ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model.TypeHierarchy.TypeHierarchyMapping -import io.circe.Encoder +import io.circe.{Decoder, Encoder} import io.circe.generic.extras.Configuration -import io.circe.generic.extras.semiauto.deriveConfiguredEncoder +import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder} import scala.annotation.nowarn @@ -21,6 +24,14 @@ object TypeHierarchy { @nowarn("cat=unused") implicit private val config: Configuration = Configuration.default + implicit val typeHierarchyMappingDecoder: Decoder[TypeHierarchy] = + deriveConfiguredDecoder[TypeHierarchy] + implicit val typeHierarchyEncoder: Encoder.AsObject[TypeHierarchy] = deriveConfiguredEncoder[TypeHierarchy] + + val context: ContextValue = ContextValue(contexts.typeHierarchy) + + implicit val typeHierarchyJsonLdEncoder: JsonLdEncoder[TypeHierarchy] = + JsonLdEncoder.computeFromCirce(context) } diff --git a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchyRejection.scala b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchyRejection.scala index 29310a60b0..2d2f3f5b51 100644 --- a/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchyRejection.scala +++ b/delta/sdk/src/main/scala/ch/epfl/bluebrain/nexus/delta/sdk/typehierarchy/model/TypeHierarchyRejection.scala @@ -1,6 +1,13 @@ package ch.epfl.bluebrain.nexus.delta.sdk.typehierarchy.model +import akka.http.scaladsl.model.StatusCodes import ch.epfl.bluebrain.nexus.delta.kernel.error.Rejection +import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.ContextValue +import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder +import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.HttpResponseFields +import io.circe.syntax.EncoderOps +import io.circe.{Encoder, JsonObject} sealed abstract class TypeHierarchyRejection(val reason: String) extends Rejection @@ -45,4 +52,17 @@ object TypeHierarchyRejection { */ final case object TypeHierarchyAlreadyExists extends TypeHierarchyRejection(s"Type hierarchy already exists.") + implicit val typeHierarchyRejectionEncoder: Encoder.AsObject[TypeHierarchyRejection] = + Encoder.AsObject.instance(r => JsonObject.singleton("reason", r.reason.asJson)) + + implicit val typeHierarchyRejectionJsonLdEncoder: JsonLdEncoder[TypeHierarchyRejection] = + JsonLdEncoder.computeFromCirce(ContextValue(contexts.error)) + + implicit val typeHierarchyRejectionHttpFields: HttpResponseFields[TypeHierarchyRejection] = + HttpResponseFields { + case TypeHierarchyDoesNotExist => StatusCodes.NotFound + case TypeHierarchyAlreadyExists => StatusCodes.Conflict + case _ => StatusCodes.BadRequest + } + } 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 1d7ed6c444..7aff6a877f 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 @@ -46,6 +46,7 @@ trait RouteFixtures { contexts.supervision -> ContextValue.fromFile("contexts/supervision.json"), contexts.suites -> ContextValue.fromFile("contexts/suites.json"), contexts.tags -> ContextValue.fromFile("contexts/tags.json"), + contexts.typeHierarchy -> ContextValue.fromFile("contexts/type-hierarchy.json"), contexts.version -> ContextValue.fromFile("contexts/version.json"), contexts.quotas -> ContextValue.fromFile("contexts/quotas.json") ) diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/create.sh b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/create.sh new file mode 100644 index 0000000000..ef0455fb94 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/create.sh @@ -0,0 +1,13 @@ +curl -X POST \ + -H "Content-Type: application/json" \ + "http://localhost:8080/v1/type-hierarchy" \ + -d \ +'{ + "mapping": { + "https://schema.org/VideoGame": [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + } +}' \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/created.json b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/created.json new file mode 100644 index 0000000000..b6d2be0c4f --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/created.json @@ -0,0 +1,23 @@ +{ + "@context": [ + "https://bluebrain.github.io/nexus/contexts/type-hierarchy.json", + "https://bluebrain.github.io/nexus/contexts/metadata.json" + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/TypeHierarchy", + "@type": "TypeHierarchy", + "mapping": { + "https://schema.org/VideoGame": [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + }, + "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/type-hierarchy.json", + "_createdAt": "1970-01-01T00:00:00Z", + "_createdBy": "http://localhost/v1/realms/qyjcmbqlkavvsbbo/users/jane-doe", + "_deprecated": false, + "_rev": 1, + "_self": "http://localhost/v1/type-hierarchy", + "_updatedAt": "1970-01-01T00:00:00Z", + "_updatedBy": "http://localhost/v1/realms/qyjcmbqlkavvsbbo/users/jane-doe" +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetch.sh b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetch.sh new file mode 100644 index 0000000000..cd93b72c5a --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetch.sh @@ -0,0 +1 @@ +curl "http://localhost:8080/v1/type-hierarchy \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetched.json b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetched.json new file mode 100644 index 0000000000..7bf152b099 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/fetched.json @@ -0,0 +1,23 @@ +{ + "@context" : [ + "https://bluebrain.github.io/nexus/contexts/type-hierarchy.json", + "https://bluebrain.github.io/nexus/contexts/metadata.json" + ], + "@id" : "https://bluebrain.github.io/nexus/vocabulary/TypeHierarchy", + "@type" : "TypeHierarchy", + "mapping" : { + "https://schema.org/VideoGame" : [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + }, + "_constrainedBy" : "https://bluebrain.github.io/nexus/schemas/type-hierarchy.json", + "_createdAt" : "1970-01-01T00:00:00Z", + "_createdBy" : "http://localhost/v1/realms/sanwxierjnyajvxn/users/jane-doe", + "_deprecated" : false, + "_rev" : 1, + "_self" : "http://localhost/v1/type-hierarchy", + "_updatedAt" : "1970-01-01T00:00:00Z", + "_updatedBy" : "http://localhost/v1/realms/sanwxierjnyajvxn/users/jane-doe" +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/payload.json b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/payload.json new file mode 100644 index 0000000000..fc4331bbc8 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/payload.json @@ -0,0 +1,9 @@ +{ + "mapping": { + "https://schema.org/VideoGame": [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + } +} \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/update.sh b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/update.sh new file mode 100644 index 0000000000..81ed2a27cd --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/update.sh @@ -0,0 +1,13 @@ +curl -X PUT \ + -H "Content-Type: application/json" \ + "http://localhost:8080/v1/type-hierarchy?rev=1" \ + -d \ +'{ + "mapping": { + "https://schema.org/VideoGame": [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + } +}' \ No newline at end of file diff --git a/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/updated.json b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/updated.json new file mode 100644 index 0000000000..6376e18a77 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/assets/type-hierarchy/updated.json @@ -0,0 +1,24 @@ +{ + "@context": [ + "https://bluebrain.github.io/nexus/contexts/type-hierarchy.json", + "https://bluebrain.github.io/nexus/contexts/metadata.json" + ], + "@id": "https://bluebrain.github.io/nexus/vocabulary/TypeHierarchy", + "@type": "TypeHierarchy", + "mapping": { + "https://schema.org/VideoGame": [ + "https://schema.org/SoftwareApplication", + "https://schema.org/CreativeWork", + "https://schema.org/Thing" + ] + }, + "_constrainedBy": "https://bluebrain.github.io/nexus/schemas/type-hierarchy.json", + "_createdAt": "1970-01-01T00:00:00Z", + "_createdBy": "http://localhost/v1/realms/knntwmnuxprsqrio/users/jane-doe", + "_deprecated": false, + "_rev": 2, + "_self": "http://localhost/v1/type-hierarchy", + "_updatedAt": "1970-01-01T00:00:00Z", + "_updatedBy": "http://localhost/v1/realms/knntwmnuxprsqrio/users/jane-doe" +} + diff --git a/docs/src/main/paradox/docs/delta/api/index.md b/docs/src/main/paradox/docs/delta/api/index.md index 6363a7aa4e..443f04e6d3 100644 --- a/docs/src/main/paradox/docs/delta/api/index.md +++ b/docs/src/main/paradox/docs/delta/api/index.md @@ -26,6 +26,7 @@ * @ref:[Graph Analytics](graph-analytics-api.md) * @ref:[Jira integration](jira.md) * @ref:[Supervision](supervision-api.md) +* @ref:[Type Hierarchy](type-hierarchy-api.md) @@@ diff --git a/docs/src/main/paradox/docs/delta/api/type-hierarchy-api.md b/docs/src/main/paradox/docs/delta/api/type-hierarchy-api.md new file mode 100644 index 0000000000..cec13e1385 --- /dev/null +++ b/docs/src/main/paradox/docs/delta/api/type-hierarchy-api.md @@ -0,0 +1,68 @@ +# Type hierarchy + +The type hierarchy is a singleton entity that defines a mapping between concrete types and their abstract types. + +@@@ note { .tip title="Authorization notes" } + +When modifying the type hierarchy, the caller must have `resources/read` permission at root level. + +Please visit @ref:[Authentication & authorization](authentication.md) section to learn more about it. + +@@@ + +## Create using POST + +``` +POST /v1/type-hierarchy + {...} +``` + +**Example** + +Request +: @@snip [create.sh](assets/type-hierarchy/create.sh) + +Payload +: @@snip [payload.json](assets/type-hierarchy/payload.json) + +Response +: @@snip [created.json](assets/type-hierarchy/created.json) + +## Update + +This operation overrides the payload. + +In order to ensure a client does not perform any changes to a resource without having had seen the previous revision of +the resource, the last revision needs to be passed as a query parameter. + +``` +PUT /v1/type-hierarchy?rev={previous_rev} + {...} +``` + +... where `{previous_rev}` is the last known revision number for the schema. + +**Example** + +Request +: @@snip [update.sh](assets/type-hierarchy/update.sh) + +Payload +: @@snip [payload.json](assets/type-hierarchy/payload.json) + +Response +: @@snip [updated.json](assets/type-hierarchy/updated.json) + +## Fetch + +``` +GET /v1/type-hierarchy +``` + +**Example** + +Request +: @@snip [schema-fetch.sh](assets/type-hierarchy/fetch.sh) + +Response +: @@snip [schema-fetched.json](assets/type-hierarchy/fetched.json) \ No newline at end of file diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala index 49e2576c80..9325ae5990 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/Identity.scala @@ -102,7 +102,11 @@ object Identity extends Generators { val Writer = UserCredentials(genString(), genString(), testRealm) } + object typehierarchy { + val Writer = UserCredentials(genString(), genString(), testRealm) + } + lazy val allUsers = - userPermissions.UserWithNoPermissions :: userPermissions.UserWithPermissions :: acls.Marge :: archives.Tweety :: compositeviews.Jerry :: events.BugsBunny :: listings.Bob :: listings.Alice :: aggregations.Charlie :: aggregations.Rose :: orgs.Fry :: orgs.Leela :: projects.Bojack :: projects.PrincessCarolyn :: resources.Rick :: resources.Morty :: storages.Coyote :: views.ScoobyDoo :: mash.Radar :: supervision.Mickey :: files.Writer :: Nil + userPermissions.UserWithNoPermissions :: userPermissions.UserWithPermissions :: acls.Marge :: archives.Tweety :: compositeviews.Jerry :: events.BugsBunny :: listings.Bob :: listings.Alice :: aggregations.Charlie :: aggregations.Rose :: orgs.Fry :: orgs.Leela :: projects.Bojack :: projects.PrincessCarolyn :: resources.Rick :: resources.Morty :: storages.Coyote :: views.ScoobyDoo :: mash.Radar :: supervision.Mickey :: files.Writer :: typehierarchy.Writer :: Nil } diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/types/AclListing.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/types/AclListing.scala index babe14c704..eb482686dd 100644 --- a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/types/AclListing.scala +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/iam/types/AclListing.scala @@ -157,6 +157,13 @@ object Permission { val list: List[Permission] = Read :: Nil } + object TypeHierarchy { + val name = "typehierarchy" + val Write: Permission = Permission(name, "write") + + val list: List[Permission] = Write :: Nil + } + object Export { val name = "export" val Run: Permission = Permission(name, "run") @@ -180,7 +187,8 @@ object Permission { Storages.list ++ Quotas.list ++ Export.list ++ - Supervision.list).toSet + Supervision.list ++ + TypeHierarchy.list).toSet val adminPermissions: Set[Permission] = (Version.list ++ diff --git a/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/TypeHierarchySpec.scala b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/TypeHierarchySpec.scala new file mode 100644 index 0000000000..c42da55998 --- /dev/null +++ b/tests/src/test/scala/ch/epfl/bluebrain/nexus/tests/kg/TypeHierarchySpec.scala @@ -0,0 +1,130 @@ +package ch.epfl.bluebrain.nexus.tests.kg + +import akka.http.scaladsl.model.StatusCodes +import cats.effect.IO +import cats.effect.Ref +import ch.epfl.bluebrain.nexus.tests.BaseIntegrationSpec +import ch.epfl.bluebrain.nexus.tests.Identity.{typehierarchy, Anonymous} +import ch.epfl.bluebrain.nexus.tests.iam.types.Permission.TypeHierarchy +import io.circe.Json +import io.circe.syntax.{EncoderOps, KeyOps} +import org.scalatest.Assertion + +import java.time.Instant + +class TypeHierarchySpec extends BaseIntegrationSpec { + + private val mapping = + Json.obj( + "mapping" := Json.obj( + "https://schema.org/VideoGame" := Json.arr( + "https://schema.org/SoftwareApplication".asJson, + "https://schema.org/Thing".asJson + ) + ) + ) + + override def beforeAll(): Unit = { + super.beforeAll() + aclDsl.addPermission("/", typehierarchy.Writer, TypeHierarchy.Write).accepted + deltaClient + .post[Json]("/type-hierarchy", mapping, typehierarchy.Writer) { (_, _) => + assert(true) + } + .accepted + () + } + + private val typeHierarchyRevisionRef = Ref.unsafe[IO, Int](0) + private def currentRev: Int = typeHierarchyRevisionRef.get.accepted + + "type hierarchy" should { + + "not be created without permissions" in { + deltaClient.post[Json]("/type-hierarchy", mapping, Anonymous) { (json, response) => + response.status shouldEqual StatusCodes.Forbidden + json shouldEqual authorizationFailed("POST") + } + } + + "not be created if it already exists" in { + deltaClient.post[Json]("/type-hierarchy", mapping, typehierarchy.Writer) { (error, response) => + response.status shouldEqual StatusCodes.Conflict + error shouldEqual typeHierarchyAlreadyExists + } + } + + "be fetched" in { + deltaClient.get[Json]("/type-hierarchy", Anonymous) { (json, response) => + response.status shouldEqual StatusCodes.OK + typeHierarchyRevisionRef.set(json.hcursor.get[Int]("_rev").rightValue).accepted + json.hcursor.get[Json]("mapping").rightValue shouldEqual mapping.hcursor.get[Json]("mapping").rightValue + assertMetadata(json, rev = currentRev) + } + } + + "not be fetched with invalid revision" in { + val rev = currentRev + deltaClient.get[Json](s"/type-hierarchy?rev=${rev + 1}", Anonymous) { (json, response) => + response.status shouldEqual StatusCodes.BadRequest + json shouldEqual revisionNotFound(rev + 1, rev) + } + } + + "not be updated without permissions" in { + val rev = currentRev + deltaClient.put[Json](s"/type-hierarchy?rev=$rev", mapping, Anonymous) { (json, response) => + response.status shouldEqual StatusCodes.Forbidden + json shouldEqual authorizationFailed("PUT", Some(rev)) + } + } + + "be updated with write permissions" in { + val rev = currentRev + deltaClient.put[Json](s"/type-hierarchy?rev=$rev", mapping, typehierarchy.Writer) { (json, response) => + response.status shouldEqual StatusCodes.OK + json.hcursor.get[Int]("_rev").rightValue shouldEqual (rev + 1) + assertMetadata(json, rev = rev + 1) + } + } + + } + + def assertMetadata(json: Json, rev: Int): Assertion = { + json.hcursor.get[Instant]("_createdAt").toOption should not be empty + json.hcursor.get[Instant]("_updatedAt").toOption should not be empty + json.hcursor.get[String]("_createdBy").toOption should not be empty + json.hcursor.get[String]("_updatedBy").toOption should not be empty + json.hcursor.get[String]("_self").toOption should not be empty + json.hcursor.get[String]("_constrainedBy").toOption should not be empty + json.hcursor.get[Boolean]("_deprecated").rightValue shouldEqual false + json.hcursor.get[Int]("_rev").rightValue shouldEqual rev + } + + def revisionNotFound(requestedRev: Int, latestRev: Int): Json = + json"""{ + "@context" : "https://bluebrain.github.io/nexus/contexts/error.json", + "reason":"Revision requested '$requestedRev' not found, last known revision is '$latestRev'." + }""" + + def authorizationFailed(method: String, rev: Option[Int] = None): Json = + json""" + { + "@context" : "https://bluebrain.github.io/nexus/contexts/error.json", + "@type" : "AuthorizationFailed", + "reason" : "The supplied authentication is not authorized to access this resource.", + "details" : "Permission 'typehierarchy/write' is missing on '/'.\\nIncoming request was 'http://localhost:8080/v1/type-hierarchy${rev + .map(r => s"?rev=$r") + .getOrElse("")}' ('$method')." + } + """ + + def typeHierarchyAlreadyExists: Json = + json""" + { + "@context" : "https://bluebrain.github.io/nexus/contexts/error.json", + "reason" : "Type hierarchy already exists." + } + """ + +}