Skip to content

Commit

Permalink
Allow creation of a tagged resource (#4339)
Browse files Browse the repository at this point in the history
  • Loading branch information
dantb authored Oct 13, 2023
1 parent 9b96b5e commit 725afec
Show file tree
Hide file tree
Showing 13 changed files with 327 additions and 160 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import akka.http.scaladsl.model.StatusCodes.{Created, OK}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import cats.syntax.all._
import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.schemas
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.routes.ResourcesRoutes.asSourceWithMetadata
import ch.epfl.bluebrain.nexus.delta.sdk._
import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._
Expand Down Expand Up @@ -79,26 +79,29 @@ final class ResourcesRoutes(
resolveProjectRef.apply { ref =>
concat(
// Create a resource without schema nor id segment
(post & pathEndOrSingleSlash & noParameter("rev") & entity(as[NexusSource]) & indexingMode) {
(source, mode) =>
(post & pathEndOrSingleSlash & noParameter("rev") & entity(as[NexusSource]) & indexingMode & tagParam) {
(source, mode, tag) =>
authorizeFor(ref, Write).apply {
emit(
Created,
resources.create(ref, resourceSchema, source.value).tapEval(indexUIO(ref, _, mode)).map(_.void)
resources
.create(ref, resourceSchema, source.value, tag)
.tapEval(indexUIO(ref, _, mode))
.map(_.void)
)
}
},
(idSegment & indexingMode) { (schema, mode) =>
val schemaOpt = underscoreToOption(schema)
concat(
// Create a resource with schema but without id segment
(post & pathEndOrSingleSlash & noParameter("rev")) {
(post & pathEndOrSingleSlash & noParameter("rev") & tagParam) { tag =>
authorizeFor(ref, Write).apply {
entity(as[NexusSource]) { source =>
emit(
Created,
resources
.create(ref, schema, source.value)
.create(ref, schema, source.value, tag)
.tapEval(indexUIO(ref, _, mode))
.map(_.void)
.rejectWhen(wrongJsonOrNotFound)
Expand All @@ -113,18 +116,18 @@ final class ResourcesRoutes(
// Create or update a resource
put {
authorizeFor(ref, Write).apply {
(parameter("rev".as[Int].?) & pathEndOrSingleSlash & entity(as[NexusSource])) {
case (None, source) =>
(parameter("rev".as[Int].?) & pathEndOrSingleSlash & entity(as[NexusSource]) & tagParam) {
case (None, source, tag) =>
// Create a resource with schema and id segments
emit(
Created,
resources
.create(id, ref, schema, source.value)
.create(id, ref, schema, source.value, tag)
.tapEval(indexUIO(ref, _, mode))
.map(_.void)
.rejectWhen(wrongJsonOrNotFound)
)
case (Some(rev), source) =>
case (Some(rev), source, _) =>
// Update a resource
emit(
resources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.generators.{ProjectGen, ResourceResolut
import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.implicits._
import ch.epfl.bluebrain.nexus.delta.sdk.model.ResourceUris
import ch.epfl.bluebrain.nexus.delta.sdk.model.{IdSegmentRef, ResourceUris}
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{events, resources}
import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContextDummy
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ApiMappings
Expand All @@ -29,6 +29,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.resources.{Resources, ResourcesConfig,
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}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{ProjectRef, ResourceRef}
import ch.epfl.bluebrain.nexus.testkit.bio.IOFromMap
import io.circe.{Json, Printer}
Expand Down Expand Up @@ -61,12 +62,19 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap {
private val schemaSource = jsonContentOf("resources/schema.json").addContext(contexts.shacl, contexts.schemasMetadata)
private val schema1 = SchemaGen.schema(nxv + "myschema", project.value.ref, schemaSource.removeKeys(keywords.id))
private val schema2 = SchemaGen.schema(schema.Person, project.value.ref, schemaSource.removeKeys(keywords.id))

private val myId = nxv + "myid" // Resource created against no schema with id present on the payload
private val myId2 = nxv + "myid2" // Resource created against schema1 with id present on the payload
private val myId3 = nxv + "myid3" // Resource created against no schema with id passed and present on the payload
private val myId4 = nxv + "myid4" // Resource created against schema1 with id passed and present on the payload
private val myId5 = nxv + "myid5" // Resource created against schema1 with id passed and present on the payload
private val tag = UserTag.unsafe("mytag")

private val myId = nxv + "myid" // Resource created against no schema with id present on the payload
private val myId2 = nxv + "myid2" // Resource created against schema1 with id present on the payload
private val myId3 = nxv + "myid3" // Resource created against no schema with id passed and present on the payload
private val myId4 = nxv + "myid4" // Resource created against schema1 with id passed and present on the payload
private val myId5 = nxv + "myid5" // Resource created against schema1 with id passed and present on the payload
private val myId6 = nxv + "myid6" // Resource created and tagged against no schema with id present on the payload
private val myId7 = nxv + "myid7" // Resource created and tagged against no schema with id present on the payload
private val myId8 =
nxv + "myid8" // Resource created and tagged against no schema with id passed and present on the payload
private val myId9 =
nxv + "myid9" // Resource created and tagged against schema1 with id passed and present on the payload
private val myIdEncoded = UrlUtils.encode(myId.toString)
private val myId2Encoded = UrlUtils.encode(myId2.toString)
private val payload = jsonContentOf("resources/resource.json", "id" -> myId)
Expand Down Expand Up @@ -94,26 +102,33 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap {
private val fetchContext = FetchContextDummy(List(project.value), ProjectContextRejection)
private val resolverContextResolution: ResolverContextResolution = ResolverContextResolution(rcr)

private def routesWithDecodingOption(implicit decodingOption: DecodingOption) = {
private def routesWithDecodingOption(implicit decodingOption: DecodingOption): (Route, Resources) = {
val resources = ResourcesImpl(
validator,
fetchContext,
resolverContextResolution,
ResourcesConfig(eventLogConfig, decodingOption),
xas
)
Route.seal(
ResourcesRoutes(
IdentitiesDummy(caller),
aclCheck,
resources,
DeltaSchemeDirectives(fetchContext, ioFromMap(uuid -> projectRef.organization), ioFromMap(uuid -> projectRef)),
IndexingAction.noop
)
(
Route.seal(
ResourcesRoutes(
IdentitiesDummy(caller),
aclCheck,
resources,
DeltaSchemeDirectives(
fetchContext,
ioFromMap(uuid -> projectRef.organization),
ioFromMap(uuid -> projectRef)
),
IndexingAction.noop
)
),
resources
)
}

private lazy val routes = routesWithDecodingOption(DecodingOption.Strict)
private lazy val routes = routesWithDecodingOption(DecodingOption.Strict)._1

private val payloadUpdated = payload deepMerge json"""{"name": "Alice", "address": null}"""

Expand Down Expand Up @@ -149,6 +164,22 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap {
}
}

"create a tagged resource" in {
val endpoints = List(
("/v1/resources/myorg/myproject?tag=mytag", myId6, schemas.resources),
("/v1/resources/myorg/myproject/myschema?tag=mytag", myId7, schema1.id)
)
val (routes, resources) = routesWithDecodingOption(DecodingOption.Strict)
forAll(endpoints) { case (endpoint, id, schema) =>
val payload = jsonContentOf("resources/resource.json", "id" -> id)
Post(endpoint, payload.toEntity) ~> routes ~> check {
status shouldEqual StatusCodes.Created
response.asJson shouldEqual resourceMetadata(projectRef, id, schema, (nxv + "Custom").toString)
lookupResourceByTag(resources, id) should contain(tag)
}
}
}

"create a resource with an authenticated user and provided id" in {
val endpoints = List(
("/v1/resources/myorg/myproject/_/myid3", myId3, schemas.resources),
Expand All @@ -161,10 +192,29 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap {
response.asJson shouldEqual
resourceMetadata(projectRef, id, schema, (nxv + "Custom").toString, createdBy = alice, updatedBy = alice)
}
}
}

"create a tagged resource with an authenticated user and provided id" in {
val endpoints = List(
("/v1/resources/myorg/myproject/_/myid8?tag=mytag", myId8, schemas.resources),
("/v1/resources/myorg/myproject/myschema/myid9?tag=mytag", myId9, schema1.id)
)
val (routes, resources) = routesWithDecodingOption(DecodingOption.Strict)
forAll(endpoints) { case (endpoint, id, schema) =>
val payload = jsonContentOf("resources/resource.json", "id" -> id)
Put(endpoint, payload.toEntity) ~> asAlice ~> routes ~> check {
status shouldEqual StatusCodes.Created
response.asJson shouldEqual
resourceMetadata(projectRef, id, schema, (nxv + "Custom").toString, createdBy = alice, updatedBy = alice)
lookupResourceByTag(resources, id) should contain(tag)
}
}
}

def lookupResourceByTag(resources: Resources, id: Iri): List[UserTag] =
resources.fetch(IdSegmentRef(id, tag), projectRef, None).accepted.value.tags.value.keys.toList

"reject the creation of a resource which already exists" in {
Put("/v1/resources/myorg/myproject/_/myid", payload.toEntity) ~> routes ~> check {
status shouldEqual StatusCodes.Conflict
Expand Down Expand Up @@ -210,7 +260,7 @@ class ResourcesRoutesSpec extends BaseRouteSpec with IOFromMap {
}

"succeed if underscore fields are present but the decoding is set to lenient" in {
val lenientDecodingRoutes = routesWithDecodingOption(DecodingOption.Lenient)
val lenientDecodingRoutes = routesWithDecodingOption(DecodingOption.Lenient)._1

Post("/v1/resources/myorg/myproject/_/", payloadWithUnderscoreFields.toEntity) ~> lenientDecodingRoutes ~> check {
response.status shouldEqual StatusCodes.Created
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ object Vocabulary {
val score = Metadata("score")
val self = Metadata("self")
val source = Metadata("source")
val tags = Metadata("tags")
val tokenEndpoint = Metadata("tokenEndpoint")
val total = Metadata("total")
val types = Metadata("types")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ trait UriDirectives extends QueryParamsUnmarshalling {
)
}

/**
* Creates optional [[UserTag]] from `tag` query param.
*/
val tagParam: Directive1[Option[UserTag]] = parameter("tag".as[UserTag].?)

def timeRange(paramName: String): Directive1[TimeRange] = parameter(paramName.as[String].?).flatMap {
case None => provide(TimeRange.default)
case Some(value) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ trait Resources {
def create(
projectRef: ProjectRef,
schema: IdSegment,
source: Json
source: Json,
tag: Option[UserTag]
)(implicit caller: Caller): IO[ResourceRejection, DataResource]

/**
Expand All @@ -61,7 +62,8 @@ trait Resources {
id: IdSegment,
projectRef: ProjectRef,
schema: IdSegment,
source: Json
source: Json,
tag: Option[UserTag]
)(implicit caller: Caller): IO[ResourceRejection, DataResource]

/**
Expand Down Expand Up @@ -261,10 +263,12 @@ object Resources {

private[delta] def next(state: Option[ResourceState], event: ResourceEvent): Option[ResourceState] = {
// format: off
def created(e: ResourceCreated): Option[ResourceState] =
def created(e: ResourceCreated): Option[ResourceState] = {
val tags = e.tag.fold(Tags.empty)(t => Tags(t -> e.rev))
Option.when(state.isEmpty){
ResourceState(e.id, e.project, e.schemaProject, e.source, e.compacted, e.expanded, e.remoteContexts, e.rev, deprecated = false, e.schema, e.types, Tags.empty, e.instant, e.subject, e.instant, e.subject)
ResourceState(e.id, e.project, e.schemaProject, e.source, e.compacted, e.expanded, e.remoteContexts, e.rev, deprecated = false, e.schema, e.types, tags, e.instant, e.subject, e.instant, e.subject)
}
}

def updated(e: ResourceUpdated): Option[ResourceState] = state.map {
_.copy(rev = e.rev, types = e.types, source = e.source, compacted = e.compacted, expanded = e.expanded, remoteContexts = e.remoteContexts, updatedAt = e.instant, updatedBy = e.subject)
Expand Down Expand Up @@ -324,7 +328,7 @@ object Resources {
for {
(schemaRev, schemaProject) <- validate(c.id, expanded, c.schema, c.project, c.caller)
t <- IOUtils.instant
} yield ResourceCreated(c.id, c.project, schemaRev, schemaProject, types, c.source, compacted, expanded, remoteContextRefs, 1, t, c.subject)
} yield ResourceCreated(c.id, c.project, schemaRev, schemaProject, types, c.source, compacted, expanded, remoteContextRefs, 1, t, c.subject, c.tag)
// format: on

case _ => IO.raiseError(ResourceAlreadyExists(c.id, c.project))
Expand Down Expand Up @@ -433,6 +437,7 @@ object Resources {
ResourceState.serializer,
Tagger[ResourceEvent](
{
case r: ResourceCreated => r.tag.flatMap(t => Some(t -> r.rev))
case r: ResourceTagAdded => Some(r.tag -> r.targetRev)
case _ => None
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,30 @@ final class ResourcesImpl private (
override def create(
projectRef: ProjectRef,
schema: IdSegment,
source: Json
source: Json,
tag: Option[UserTag]
)(implicit caller: Caller): IO[ResourceRejection, DataResource] = {
for {
projectContext <- fetchContext.onCreate(projectRef)
schemeRef <- expandResourceRef(schema, projectContext)
jsonld <- sourceParser(projectRef, projectContext, source)
res <- eval(CreateResource(jsonld.iri, projectRef, schemeRef, source, jsonld, caller))
res <- eval(CreateResource(jsonld.iri, projectRef, schemeRef, source, jsonld, caller, tag))
} yield res
}.span("createResource")

override def create(
id: IdSegment,
projectRef: ProjectRef,
schema: IdSegment,
source: Json
source: Json,
tag: Option[UserTag]
)(implicit caller: Caller): IO[ResourceRejection, DataResource] = {
for {
projectContext <- fetchContext.onCreate(projectRef)
iri <- expandIri(id, projectContext)
schemeRef <- expandResourceRef(schema, projectContext)
jsonld <- sourceParser(projectRef, projectContext, iri, source)
res <- eval(CreateResource(iri, projectRef, schemeRef, source, jsonld, caller))
res <- eval(CreateResource(iri, projectRef, schemeRef, source, jsonld, caller, tag))
} yield res
}.span("createResource")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ object ResourceCommand {
schema: ResourceRef,
source: Json,
jsonld: JsonLdResult,
caller: Caller
caller: Caller,
tag: Option[UserTag]
) extends ResourceCommand {

override def rev: Int = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Tag.UserTag
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Label, ProjectRef, ResourceRef}
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto.{deriveConfiguredCodec, deriveConfiguredEncoder}
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
import io.circe.syntax._
import io.circe._

Expand Down Expand Up @@ -94,7 +94,8 @@ object ResourceEvent {
remoteContexts: Set[RemoteContextRef] = Set.empty,
rev: Int,
instant: Instant,
subject: Subject
subject: Subject,
tag: Option[UserTag]
) extends ResourceEvent

/**
Expand Down Expand Up @@ -276,7 +277,9 @@ object ResourceEvent {
// when deserializing an event that has none. Remove it after 1.10 migration.
implicit val configuration: Configuration = Serializer.circeConfiguration.withDefaults

implicit val coder: Codec.AsObject[ResourceEvent] = deriveConfiguredCodec[ResourceEvent]
implicit val enc: Encoder.AsObject[ResourceEvent] =
deriveConfiguredEncoder[ResourceEvent].mapJsonObject(dropNullValues)
implicit val coder: Codec.AsObject[ResourceEvent] = Codec.AsObject.from(deriveConfiguredDecoder[ResourceEvent], enc)
Serializer()
}

Expand Down Expand Up @@ -336,13 +339,15 @@ object ResourceEvent {
implicit val subjectEncoder: Encoder[Subject] = IriEncoder.jsonEncoder[Subject]
implicit val projectRefEncoder: Encoder[ProjectRef] = IriEncoder.jsonEncoder[ProjectRef]
Encoder.encodeJsonObject.contramapObject { event =>
deriveConfiguredEncoder[ResourceEvent]
.encodeObject(event)
val obj = deriveConfiguredEncoder[ResourceEvent].encodeObject(event)
dropNullValues(obj)
.remove("compacted")
.remove("expanded")
.remove("remoteContexts")
.add(keywords.context, context.value)
}
}
}

private def dropNullValues(j: JsonObject): JsonObject = j.filter { case (_, v) => !v.isNull }
}
Loading

0 comments on commit 725afec

Please sign in to comment.