Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Introduce OntologyService and refactor resource creation #3255

Merged
merged 12 commits into from
May 29, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import org.knora.webapi.slice.ontology.api.service.RestCardinalityService
import org.knora.webapi.slice.ontology.api.service.RestCardinalityServiceLive
import org.knora.webapi.slice.ontology.domain.service.CardinalityService
import org.knora.webapi.slice.ontology.domain.service.OntologyRepo
import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive
import org.knora.webapi.slice.ontology.repo.service.OntologyCache
import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive
import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive
Expand Down Expand Up @@ -168,6 +169,7 @@ object LayersTest {
OntologyHelpersLive.layer,
OntologyRepoLive.layer,
OntologyResponderV2Live.layer,
OntologyServiceLive.layer,
PermissionUtilADMLive.layer,
PermissionsResponder.layer,
PredicateObjectMapper.layer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import org.knora.webapi.slice.ontology.api.service.RestCardinalityService
import org.knora.webapi.slice.ontology.api.service.RestCardinalityServiceLive
import org.knora.webapi.slice.ontology.domain.service.CardinalityService
import org.knora.webapi.slice.ontology.domain.service.OntologyRepo
import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive
import org.knora.webapi.slice.ontology.repo.service.OntologyCache
import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive
import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive
Expand Down Expand Up @@ -154,6 +155,7 @@ object LayersLive {
OntologyHelpersLive.layer,
OntologyRepoLive.layer,
OntologyResponderV2Live.layer,
OntologyServiceLive.layer,
PermissionUtilADMLive.layer,
PermissionsResponder.layer,
PredicateObjectMapper.layer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ object StringFormatter {
StringFormatter.initForTest()
StringFormatter.getGeneralInstance
}

def isKnoraOntologyIri(iri: SmartIri): Boolean =
iri.isKnoraApiV2DefinitionIri && OntologyConstants.InternalOntologyLabels.contains(iri.getOntologyName)
}

/**
Expand Down Expand Up @@ -929,10 +932,7 @@ class StringFormatter private (
override def isKnoraInternalEntityIri: Boolean = isKnoraInternalDefinitionIri && isKnoraEntityIri

override def isKnoraApiV2DefinitionIri: Boolean =
iriInfo.iriType == KnoraDefinitionIri && (iriInfo.ontologySchema match {
case Some(_: ApiV2Schema) => true
case _ => false
})
iriInfo.iriType == KnoraDefinitionIri && iriInfo.ontologySchema.isInstanceOf[ApiV2Schema]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this precise? It's always going to be an instance of Option first, ApiV2Schema second. It's never a good idea to use isInstanceOf, it's not type safe, as you see here. If you're interested in the subtype, add WithAsIs and use .contains(_.is[ApiV2Schema]).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right... that's actually not at all correct, I completely ignored the option. That may be the reason why suddenly all the tests fail.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted the change which fixed the tests.
I don't think I want to spend anymore time on this PR, so I leave WithAsIs for another time.


override def isKnoraApiV2EntityIri: Boolean = isKnoraApiV2DefinitionIri && isKnoraEntityIri

Expand Down Expand Up @@ -985,16 +985,10 @@ class StringFormatter private (
}

override def getOntologyName: String =
iriInfo.ontologyName match {
case Some(name) => name
case None => throw DataConversionException(s"Expected a Knora ontology IRI: $iri")
}
iriInfo.ontologyName.getOrElse(throw DataConversionException(s"Expected a Knora ontology IRI: $iri"))

override def getEntityName: String =
iriInfo.entityName match {
case Some(name) => name
case None => throw DataConversionException(s"Expected a Knora entity IRI: $iri")
}
iriInfo.entityName.getOrElse(throw DataConversionException(s"Expected a Knora entity IRI: $iri"))

override def getOntologySchema: Option[OntologySchema] = iriInfo.ontologySchema

Expand Down Expand Up @@ -1626,16 +1620,6 @@ class StringFormatter private (
s"$mappingIri/elements/$knoraMappingElementUuid"
}

/**
* Creates an IRI used as a lock for the creation of mappings inside a given project.
* This method will always return the same IRI for the given project IRI.
*
* @param projectIri the IRI of the project the mapping will belong to.
* @return an IRI used as a lock for the creation of mappings inside a given project.
*/
def createMappingLockIriForProject(projectIri: IRI): IRI =
s"$projectIri/mappings"

/**
* Validates a custom value IRI, throwing [[BadRequestException]] if the IRI is not valid.
*
Expand All @@ -1660,18 +1644,10 @@ class StringFormatter private (
customValueIri
}

def isKnoraOntologyIri(iri: SmartIri): Boolean =
iri.isKnoraApiV2DefinitionIri && OntologyConstants.InternalOntologyLabels.contains(iri.getOntologyName)

def unescapeStringLiteralSeq(stringLiteralSeq: StringLiteralSequenceV2): StringLiteralSequenceV2 =
StringLiteralSequenceV2(
stringLiterals = stringLiteralSeq.stringLiterals.map(stringLiteral =>
StringLiteralV2.from(Iri.fromSparqlEncodedString(stringLiteral.value), stringLiteral.language),
),
)
def unescapeOptionalString(optionalString: Option[String]): Option[String] =
optionalString match {
case Some(s: String) => Some(Iri.fromSparqlEncodedString(s))
case None => None
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import org.knora.webapi.slice.admin.domain.model.User
import org.knora.webapi.slice.admin.domain.service.KnoraProjectService
import org.knora.webapi.slice.admin.domain.service.ProjectService
import org.knora.webapi.slice.ontology.domain.service.OntologyRepo
import org.knora.webapi.slice.ontology.domain.service.OntologyService
import org.knora.webapi.store.iiif.errors.SipiException
import org.knora.webapi.store.triplestore.api.TriplestoreService
import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Construct
Expand Down Expand Up @@ -89,6 +90,7 @@ final case class ResourcesResponderV2(
searchResponderV2: SearchResponderV2,
ontologyRepo: OntologyRepo,
permissionsResponder: PermissionsResponder,
ontologyService: OntologyService,
)(implicit val stringFormatter: StringFormatter)
extends MessageHandler
with LazyLogging
Expand All @@ -107,6 +109,7 @@ final case class ResourcesResponderV2(
this,
ontologyRepo,
permissionsResponder: PermissionsResponder,
ontologyService,
)

override def isResponsibleFor(message: ResponderRequest): Boolean =
Expand Down Expand Up @@ -2018,7 +2021,8 @@ object ResourcesResponderV2 {
or <- ZIO.service[OntologyRepo]
sf <- ZIO.service[StringFormatter]
pr <- ZIO.service[PermissionsResponder]
handler <- mr.subscribe(ResourcesResponderV2(config, iriS, mr, ts, cu, su, ru, pu, kps, sr, or, pr)(sf))
os <- ZIO.service[OntologyService]
handler <- mr.subscribe(ResourcesResponderV2(config, iriS, mr, ts, cu, su, ru, pu, kps, sr, or, pr, os)(sf))
} yield handler
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,7 @@ final case class StandoffResponderV2(
result <-
IriLocker.runWithIriLock(
apiRequestID,
stringFormatter
.createMappingLockIriForProject(
projectIri.toString,
), // use a special project specific IRI to lock the creation of mappings for the given project
s"${projectIri.toString}/mappings",
createMappingAndCheck(
xml = xml,
label = label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import org.knora.webapi.slice.ontology.domain.model.Cardinality.AtLeastOne
import org.knora.webapi.slice.ontology.domain.model.Cardinality.ExactlyOne
import org.knora.webapi.slice.ontology.domain.model.Cardinality.ZeroOrOne
import org.knora.webapi.slice.ontology.domain.service.OntologyRepo
import org.knora.webapi.slice.ontology.domain.service.OntologyService
import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive
import org.knora.webapi.store.triplestore.api.TriplestoreService
import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Update
import org.knora.webapi.util.ZioHelper
Expand All @@ -59,6 +61,7 @@ final case class CreateResourceV2Handler(
getResources: GetResources,
ontologyRepo: OntologyRepo,
permissionsResponder: PermissionsResponder,
ontologyService: OntologyService,
)(implicit val stringFormatter: StringFormatter)
extends LazyLogging {

Expand Down Expand Up @@ -94,77 +97,74 @@ final case class CreateResourceV2Handler(
createResourceRequestV2: CreateResourceRequestV2,
): Task[ReadResourcesSequenceV2] =
for {
// Don't allow anonymous users to create resources.
_ <- ZIO.when(createResourceRequestV2.requestingUser.isAnonymousUser) {
ZIO.fail(ForbiddenException("Anonymous users aren't allowed to create resources"))
}

// Ensure that the project isn't the system project or the shared ontologies project.
_ <- ensureNotAnonymousUser(createResourceRequestV2.requestingUser)
_ <- ensureClassBelongsToProjectOntology(createResourceRequestV2)
projectIri = createResourceRequestV2.createResource.projectADM.id
_ <-
ZIO.when(
projectIri == KnoraProjectRepo.builtIn.SystemProject.id.value || projectIri == OntologyConstants.KnoraAdmin.DefaultSharedOntologiesProject,
)(ZIO.fail(BadRequestException(s"Resources cannot be created in project <$projectIri>")))

// Ensure that the resource class isn't from a non-shared ontology in another project.

resourceClassOntologyIri: SmartIri = createResourceRequestV2.createResource.resourceClassIri.getOntologyFromEntity
readOntologyMetadataV2 <- messageRelay
.ask[ReadOntologyMetadataV2](
OntologyMetadataGetByIriRequestV2(
Set(resourceClassOntologyIri),
createResourceRequestV2.requestingUser,
),
)
ontologyMetadata <- ZIO
.fromOption(readOntologyMetadataV2.ontologies.headOption)
.orElseFail(BadRequestException(s"Ontology $resourceClassOntologyIri not found"))
ontologyProjectIri <-
ZIO
.fromOption(ontologyMetadata.projectIri)
.mapBoth(
_ => InconsistentRepositoryDataException(s"Ontology $resourceClassOntologyIri has no project"),
_.toString(),
)

_ <-
ZIO.when(
projectIri != ontologyProjectIri && !(ontologyMetadata.ontologyIri.isKnoraBuiltInDefinitionIri || ontologyMetadata.ontologyIri.isKnoraSharedDefinitionIri),
) {
val msg =
s"Cannot create a resource in project <$projectIri> with resource class <${createResourceRequestV2.createResource.resourceClassIri}>, which is defined in a non-shared ontology in another project"
ZIO.fail(BadRequestException(msg))
}

// Check user's PermissionProfile (part of UserADM) to see if the user has the permission to
// create a new resource in the given project.

internalResourceClassIri: SmartIri = createResourceRequestV2.createResource.resourceClassIri
.toOntologySchema(InternalSchema)

_ <- ZIO.when(
!createResourceRequestV2.requestingUser.permissions
.hasPermissionFor(ResourceCreateOperation(internalResourceClassIri.toString), projectIri),
) {
val msg =
s"User ${createResourceRequestV2.requestingUser.username} does not have permission to create a resource of class <${createResourceRequestV2.createResource.resourceClassIri}> in project <$projectIri>"
ZIO.fail(ForbiddenException(msg))
}
_ <- ensureUserHasPermission(createResourceRequestV2, projectIri)

resourceIri <-
iriService.checkOrCreateEntityIri(
createResourceRequestV2.createResource.resourceIri,
stringFormatter.makeRandomResourceIri(createResourceRequestV2.createResource.projectADM.shortcode),
)

// Do the remaining pre-update checks and the update while holding an update lock on the resource to be created.
taskResult <- IriLocker.runWithIriLock(
createResourceRequestV2.apiRequestID,
resourceIri,
makeTask(createResourceRequestV2, resourceIri),
)
} yield taskResult

private def ensureNotAnonymousUser(user: User): Task[Unit] =
ZIO
.when(user.isAnonymousUser)(ZIO.fail(ForbiddenException("Anonymous users aren't allowed to create resources")))
.ignore

private def ensureClassBelongsToProjectOntology(createResourceRequestV2: CreateResourceRequestV2): Task[Unit] = for {
projectIri <- ZIO.succeed(createResourceRequestV2.createResource.projectADM.id)
isSystemOrSharedProject =
projectIri == KnoraProjectRepo.builtIn.SystemProject.id.value ||
projectIri == OntologyConstants.KnoraAdmin.DefaultSharedOntologiesProject
_ <- ZIO.when(isSystemOrSharedProject)(
ZIO.fail(BadRequestException(s"Resources cannot be created in project <$projectIri>")),
)

resourceClassOntologyIri =
createResourceRequestV2.createResource.resourceClassIri.getOntologyFromEntity.toInternalIri
resourceClassProjectIri <-
ontologyService
.getProjectIriForOntologyIri(resourceClassOntologyIri)
.someOrFail(BadRequestException(s"Ontology $resourceClassOntologyIri not found"))

_ <-
ZIO
.fail(
BadRequestException(
s"Cannot create a resource in project <$projectIri> with resource class <${createResourceRequestV2.createResource.resourceClassIri}>, which is defined in a non-shared ontology in another project",
),
)
.unless(
projectIri == resourceClassProjectIri || OntologyServiceLive.isBuiltInOrSharedOntology(
resourceClassOntologyIri,
),
)
} yield ()

private def ensureUserHasPermission(createResourceRequestV2: CreateResourceRequestV2, projectIri: String) = for {
internalResourceClassIri <-
ZIO.succeed(createResourceRequestV2.createResource.resourceClassIri.toOntologySchema(InternalSchema))
_ <-
ZIO
.fail(
ForbiddenException(
s"User ${createResourceRequestV2.requestingUser.username} does not have permission to create a resource of class <${createResourceRequestV2.createResource.resourceClassIri}> in project <$projectIri>",
),
)
.unless(
createResourceRequestV2.requestingUser.permissions
.hasPermissionFor(ResourceCreateOperation(internalResourceClassIri.toString), projectIri),
)
} yield ()

private def makeTask(
createResourceRequestV2: CreateResourceRequestV2,
resourceIri: IRI,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,11 @@ object RouteUtilZ {
def decodeUuid(uuidStr: String): IO[BadRequestException, UUID] =
ZIO.attempt(UuidUtil.decode(uuidStr)).orElseFail(BadRequestException(s"Invalid value UUID: $uuidStr"))

def ensureExternalOntologyName(iri: SmartIri): ZIO[StringFormatter, BadRequestException, SmartIri] =
ZIO.serviceWithZIO[StringFormatter] { sf =>
if (sf.isKnoraOntologyIri(iri)) {
ZIO.fail(BadRequestException(s"Internal ontology <$iri> cannot be served"))
} else {
ZIO.succeed(iri)
}
def ensureExternalOntologyName(iri: SmartIri): IO[BadRequestException, SmartIri] =
if (StringFormatter.isKnoraOntologyIri(iri)) {
ZIO.fail(BadRequestException(s"Internal ontology <$iri> cannot be served"))
} else {
ZIO.succeed(iri)
}

def ensureIsKnoraOntologyIri(iri: SmartIri): IO[BadRequestException, SmartIri] =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors.
* SPDX-License-Identifier: Apache-2.0
*/

package org.knora.webapi.slice.ontology.domain.service

import zio.*

import org.knora.webapi.messages.OntologyConstants
import org.knora.webapi.slice.ontology.repo.service.OntologyCache
import org.knora.webapi.slice.resourceinfo.domain.InternalIri

trait OntologyService {
def getProjectIriForOntologyIri(ontologyIri: InternalIri): Task[Option[String]]
}

final case class OntologyServiceLive(ontologyCache: OntologyCache) extends OntologyService {
def getProjectIriForOntologyIri(ontologyIri: InternalIri): Task[Option[String]] =
ontologyCache.getCacheData.map { cacheData =>
cacheData.ontologies.map { case (k, v) => k.toString() -> v }
.get(ontologyIri.value)
.flatMap(_.ontologyMetadata.projectIri.map(_.toString()))
}
}

object OntologyServiceLive {
def isBuiltInOntology(ontologyIri: InternalIri): Boolean =
OntologyConstants.BuiltInOntologyLabels.contains(ontologyIri.value.split("/").last)

def isSharedOntology(ontologyIri: InternalIri): Boolean =
ontologyIri.value.split("/")(4) == "shared"

def isBuiltInOrSharedOntology(ontologyIri: InternalIri): Boolean =
isBuiltInOntology(ontologyIri) || isSharedOntology(ontologyIri)

val layer = ZLayer.derive[OntologyServiceLive]
}
Loading