Skip to content

Commit

Permalink
Add type hierarchy route (#4729)
Browse files Browse the repository at this point in the history
  • Loading branch information
olivergrabinski authored Feb 20, 2024
1 parent 02471bf commit 50d2688
Show file tree
Hide file tree
Showing 23 changed files with 626 additions and 4 deletions.
1 change: 1 addition & 0 deletions delta/app/src/main/resources/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ app {
"version/read",
"quotas/read",
"supervision/read",
"typehierarchy/write"
"export/run"
]

Expand Down
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
)
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ class DeltaModule(appCfg: AppConfig, config: Config)(implicit classLoader: Class
include(ExportModule)
include(StreamModule)
include(SupervisionModule)
include(TypeHierarchyModule)

}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
@@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions delta/sdk/src/main/resources/contexts/type-hierarchy.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
]
}
}'
Loading

0 comments on commit 50d2688

Please sign in to comment.