Skip to content

Commit

Permalink
feat: Support json ld ontology api (Create class) (DEV-4344) (#3467)
Browse files Browse the repository at this point in the history
  • Loading branch information
seakayone authored Jan 22, 2025
1 parent b1083e7 commit 1d69a9b
Show file tree
Hide file tree
Showing 12 changed files with 479 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -443,26 +443,6 @@ case class CreateClassRequestV2(
requestingUser: User,
) extends OntologiesResponderRequestV2

/**
* Constructs instances of [[CreateClassRequestV2]] based on JSON-LD requests.
*/
object CreateClassRequestV2 {

/**
* Converts a JSON-LD request to a [[CreateClassRequestV2]].
*
* @param document the JSON-LD input.
* @param apiRequestID the UUID of the API request.
* @param requestingUser the user making the request.
* @return a [[CreateClassRequestV2]] representing the input.
*/
def fromJsonLd(document: JsonLDDocument, apiRequestID: UUID, requestingUser: User): CreateClassRequestV2 = {
val inputOntologiesV2 = InputOntologyV2.fromJsonLD(document)
val updateInfo = OntologyUpdateHelper.getClassDef(inputOntologiesV2)
CreateClassRequestV2(updateInfo.classInfoContent, updateInfo.lastModificationDate, apiRequestID, requestingUser)
}
}

/**
* Requests the addition of cardinalities to a class. A successful response will be a [[ReadOntologyV2]].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,9 @@ final case class OntologiesRouteV2()(
entity(as[String]) { jsonRequest => requestContext =>
val requestMessageTask = for {
requestingUser <- ZIO.serviceWithZIO[Authenticator](_.getUserADM(requestContext))
requestDoc <- RouteUtilV2.parseJsonLd(jsonRequest)
apiRequestId <- RouteUtilZ.randomUuid()
requestMessage <- ZIO.attempt(CreateClassRequestV2.fromJsonLd(requestDoc, apiRequestId, requestingUser))
requestMessage <- requestParser(_.createClassRequestV2(jsonRequest, apiRequestId, requestingUser))
.mapError(BadRequestException.apply)
} yield requestMessage
RouteUtilV2.runRdfRouteZ(requestMessageTask, requestContext)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import org.knora.webapi.slice.admin.domain.model.UserIri
import org.knora.webapi.slice.admin.domain.service.ProjectService
import org.knora.webapi.slice.admin.domain.service.UserService
import org.knora.webapi.slice.common.KnoraIris.*
import org.knora.webapi.slice.common.KnoraIris.ResourceClassIri as KResourceClassIri
import org.knora.webapi.slice.common.KnoraIris.ResourceIri as KResourceIri
import org.knora.webapi.slice.common.jena.JenaConversions.given
import org.knora.webapi.slice.common.jena.ModelOps
Expand Down Expand Up @@ -97,8 +96,7 @@ final case class ApiComplexV2JsonLdRequestParser(
private def resourceClassIri(r: Resource): IO[String, ResourceClassIri] = ZIO
.fromOption(r.rdfsType)
.orElseFail("No root resource class IRI found")
.flatMap(converter.asSmartIri(_).mapError(_.getMessage))
.flatMap(iri => ZIO.fromEither(KResourceClassIri.fromApiV2Complex(iri)))
.flatMap(str => converter.asResourceClassIri(str))
}

def updateResourceMetadataRequestV2(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,16 @@ object KnoraIris {
Right(ResourceIri(iri, shortcode, resourceId))
else Left(s"<$iri> is not a Knora resource IRI")
}

final case class OntologyIri private (smartIri: SmartIri) extends KnoraIri
object OntologyIri {
def unsafeFrom(iri: SmartIri): OntologyIri = from(iri).fold(e => throw IllegalArgumentException(e), identity)

def fromApiV2Complex(iri: SmartIri): Either[String, OntologyIri] =
from(iri).filterOrElse(_.smartIri.isApiV2ComplexSchema, s"Not an API v2 complex IRI ${iri.toString}")

def from(iri: SmartIri): Either[String, OntologyIri] =
if iri.isKnoraOntologyIri then Right(OntologyIri(iri))
else Left(s"<$iri> is not a Knora ontology IRI")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright © 2021 - 2025 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.common.jena

import org.apache.jena.query.Dataset
import org.apache.jena.query.DatasetFactory
import org.apache.jena.rdf.model.Model
import org.apache.jena.rdf.model.Resource
import org.apache.jena.riot.Lang
import org.apache.jena.riot.RDFDataMgr
import zio.*

import java.io.ByteArrayInputStream
import java.nio.charset.StandardCharsets

object DatasetOps { self =>

extension (ds: Dataset) {
def printTrig: UIO[Unit] = as(Lang.TRIG).flatMap(Console.printLine(_)).logError.ignore

def asTriG: Task[String] = as(Lang.TRIG)

def as(lang: Lang): Task[String] = ZIO.attempt {
val out = new java.io.ByteArrayOutputStream()
RDFDataMgr.write(out, ds, lang)
out.toString(java.nio.charset.StandardCharsets.UTF_8)
}

def defaultModel: Model = ds.getDefaultModel

def namedModel(uri: Resource): Option[Model] = Option(ds.getNamedModel(uri))
}

private val createDataset =
ZIO.acquireRelease(ZIO.succeed(DatasetFactory.create()))(ds => ZIO.succeed(ds.close()))

def fromJsonLd(jsonLd: String): ZIO[Scope, String, Dataset] = from(jsonLd, Lang.JSONLD)

def from(str: String, lang: Lang): ZIO[Scope, String, Dataset] =
for {
ds <- createDataset
_ <- ZIO
.attempt(RDFDataMgr.read(ds, ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8)), lang))
.mapError(_.getMessage)
} yield ds
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ object ModelOps { self =>
def printTurtle: UIO[Unit] =
asTurtle.flatMap(Console.printLine(_)).logError.ignore

def asTurtle: Task[String] =
def printTriG: UIO[Unit] =
asTriG.flatMap(Console.printLine(_)).logError.ignore

def asTurtle: Task[String] = as(Lang.TURTLE)
def asTriG: Task[String] = as(Lang.TRIG)
def as(lang: Lang): Task[String] =
ZIO.attempt {
val out = new java.io.ByteArrayOutputStream()
RDFDataMgr.write(out, model, Lang.TURTLE)
RDFDataMgr.write(out, model, lang)
out.toString(java.nio.charset.StandardCharsets.UTF_8)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ object ResourceOps {
case Some(stmt) => f.apply(stmt).map(Some(_))
case None => Right(None)

def objectRdfClass(): Either[String, String] = statement(RDF.`type`).flatMap(_.objectAsUri)

def objectBigDecimal(p: Property): Either[String, BigDecimal] = statement(p).flatMap(_.objectAsBigDecimal)
def objectBigDecimalOption(p: Property): Either[String, Option[BigDecimal]] = fromStatement(p, _.objectAsBigDecimal)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,198 @@

package org.knora.webapi.slice.ontology.api

import org.apache.jena.rdf.model.*
import org.apache.jena.vocabulary.OWL2 as OWL
import org.apache.jena.vocabulary.RDFS
import zio.*

import java.time.Instant
import java.util.UUID
import scala.jdk.CollectionConverters.*
import scala.language.implicitConversions

import dsp.constants.SalsahGui
import org.knora.webapi.ApiV2Complex
import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.*
import org.knora.webapi.messages.SmartIri
import org.knora.webapi.messages.store.triplestoremessages.BooleanLiteralV2
import org.knora.webapi.messages.store.triplestoremessages.OntologyLiteralV2
import org.knora.webapi.messages.store.triplestoremessages.SmartIriLiteralV2
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
import org.knora.webapi.messages.v2.responder.ontologymessages.ChangeOntologyMetadataRequestV2
import org.knora.webapi.messages.v2.responder.ontologymessages.ClassInfoContentV2
import org.knora.webapi.messages.v2.responder.ontologymessages.CreateClassRequestV2
import org.knora.webapi.messages.v2.responder.ontologymessages.OwlCardinality.KnoraCardinalityInfo
import org.knora.webapi.messages.v2.responder.ontologymessages.PredicateInfoV2
import org.knora.webapi.slice.admin.domain.model.User
import org.knora.webapi.slice.common.KnoraIris
import org.knora.webapi.slice.common.KnoraIris.OntologyIri
import org.knora.webapi.slice.common.KnoraIris.ResourceClassIri
import org.knora.webapi.slice.common.jena.DatasetOps
import org.knora.webapi.slice.common.jena.DatasetOps.*
import org.knora.webapi.slice.common.jena.JenaConversions.given_Conversion_String_Property
import org.knora.webapi.slice.common.jena.ModelOps
import org.knora.webapi.slice.common.jena.ModelOps.*
import org.knora.webapi.slice.common.jena.ResourceOps.*
import org.knora.webapi.slice.common.jena.StatementOps.*
import org.knora.webapi.slice.ontology.domain.model.Cardinality
import org.knora.webapi.slice.resourceinfo.domain.IriConverter

final case class OntologyV2RequestParser(iriConverter: IriConverter) {

private final case class OntologyMetadata(
ontologyIri: OntologyIri,
label: Option[String],
comment: Option[String],
lastModificationDate: Instant,
)

def changeOntologyMetadataRequestV2(
jsonLd: String,
apiRequestId: UUID,
requestingUser: User,
): IO[String, ChangeOntologyMetadataRequestV2] = ZIO.scoped {
for {
model <- ModelOps.fromJsonLd(jsonLd)
r <- ZIO.fromEither(model.singleRootResource)
ontologyIri: SmartIri <-
ZIO.fromOption(r.uri).orElseFail("No IRI found").flatMap(iriConverter.asSmartIri(_).mapError(_.getMessage))
label <- ZIO.fromEither(r.objectStringOption(RDFS.label))
comment <- ZIO.fromEither(r.objectStringOption(RDFS.comment))
lastModificationDate <- ZIO.fromEither(r.objectInstant(LastModificationDate))
meta <- extractOntologyMetadata(model)
} yield ChangeOntologyMetadataRequestV2(
ontologyIri,
label,
comment,
lastModificationDate,
meta.ontologyIri.smartIri,
meta.label,
meta.comment,
meta.lastModificationDate,
apiRequestId,
requestingUser,
)
}

private def extractOntologyMetadata(m: Model): ZIO[Scope, String, OntologyMetadata] =
for {
r <- ZIO.fromEither(m.singleRootResource).orElseFail("No root resource found")
ontologyIri <- uriAsOntologyIri(r)
label <- ZIO.fromEither(r.objectStringOption(RDFS.label))
comment <- ZIO.fromEither(r.objectStringOption(RDFS.comment))
lastModificationDate <- ZIO.fromEither(r.objectInstant(LastModificationDate))
} yield OntologyMetadata(ontologyIri, label, comment, lastModificationDate)

private def uriAsOntologyIri(r: Resource): ZIO[Scope, String, OntologyIri] = ZIO
.fromOption(r.uri)
.orElseFail("No IRI found")
.flatMap(iriConverter.asSmartIri(_).mapError(_.getMessage))
.flatMap(sIri => ZIO.fromEither(OntologyIri.fromApiV2Complex(sIri)))

def createClassRequestV2(jsonLd: String, apiRequestId: UUID, requestingUser: User): IO[String, CreateClassRequestV2] =
ZIO.scoped {
for {
ds <- DatasetOps.fromJsonLd(jsonLd)
meta <- extractOntologyMetadata(ds.defaultModel)
classModel <- ZIO.fromOption(ds.namedModel(meta.ontologyIri.toString)).orElseFail("No class definition found")
classInfo <- extractClassInfo(classModel)
_ <-
ZIO
.fail(
s"Ontology for class '${classInfo.classIri.toString}' does not match ontology ${meta.ontologyIri.toString}",
)
.unless(meta.ontologyIri.smartIri == classInfo.classIri.getOntologyFromEntity)
} yield CreateClassRequestV2(
classInfo,
meta.lastModificationDate,
apiRequestId,
requestingUser,
)
}

private def extractClassInfo(classModel: Model): ZIO[Scope, String, ClassInfoContentV2] =
for {
r <- ZIO.fromEither(classModel.singleRootResource)
classIri <- extractClassIri(r)
predicates <- extractPredicates(r)
cardinalities <- extractCardinalities(r)
datatypeInfo = None
subClasses <- extractSubClasses(r).map(_.map(_.smartIri))
} yield ClassInfoContentV2(classIri.smartIri, predicates, cardinalities, datatypeInfo, subClasses, ApiV2Complex)

private def extractClassIri(r: Resource): ZIO[Scope, String, ResourceClassIri] =
ZIO.fromOption(r.uri).orElseFail("No class IRI found").flatMap(str => iriConverter.asResourceClassIri(str))

private def extractPredicates(r: Resource): ZIO[Scope, String, Map[SmartIri, PredicateInfoV2]] =
val propertyIter = r
.listProperties()
.asScala
.filterNot(_.predicateUri == null)
.filterNot(_.predicateUri == RDFS.subPropertyOf.toString)
.filterNot(_.predicateUri == RDFS.subClassOf.toString)
.toList
ZIO.foreach(propertyIter)(extractPredicateInfo).map(_.toMap).logError

private def extractPredicateInfo(stmt: Statement): ZIO[Scope, String, (SmartIri, PredicateInfoV2)] =
for {
propertyIri <- iriConverter.asSmartIri(stmt.predicateUri).mapError(_.getMessage)
objects <- asPredicateInfoV2(stmt.getObject)
} yield (propertyIri, PredicateInfoV2(propertyIri, List(objects)))

private def asPredicateInfoV2(node: RDFNode): ZIO[Scope, String, OntologyLiteralV2] =
node match
case res: Resource => iriConverter.asSmartIri(res.getURI).mapBoth(_.getMessage, SmartIriLiteralV2.apply)
case literal: Literal => {
literal.getValue match
case str: String => ZIO.succeed(StringLiteralV2.from(str, Option(literal.getLanguage)))
case b: java.lang.Boolean => ZIO.succeed(BooleanLiteralV2(b))
case _ => ZIO.fail(s"Unsupported literal type: ${literal.getValue.getClass}")
}

private def extractSubClasses(r: Resource): ZIO[Scope, String, Set[ResourceClassIri]] = {
val subclasses: Set[String] = r.listProperties(RDFS.subClassOf).asScala.flatMap(_.objectAsUri.toOption).toSet
iriConverter.asResourceClassIris(subclasses)
}

private def extractCardinalities(r: Resource): ZIO[Scope, String, Map[SmartIri, KnoraCardinalityInfo]] = {
val cardinalities: Either[String, Map[String, KnoraCardinalityInfo]] =
r.listProperties(RDFS.subClassOf)
.asScala
.flatMap(asKnoraCardinalityResource)
.map { res =>
for {
prop <- res.objectUri(OWL.onProperty)
card <- asKnoraCardinalityInfo(res)
} yield (prop, card)
}
.foldLeft(Right(Map.empty))(
(
acc: Either[String, Map[String, KnoraCardinalityInfo]],
elem: Either[String, (String, KnoraCardinalityInfo)],
) =>
(acc, elem) match
case (Right(accMap), Right(elemTuple)) => Right(accMap + elemTuple)
case (Left(err), Left(err2)) => Left(err + "," + err2)
case (Left(err), _) => Left(err)
case (_, Left(err)) => Left(err),
)

ZIO
.fromEither(cardinalities)
.flatMap(ZIO.foreach(_) { case (key, value) => iriConverter.asPropertyIri(key).map(p => (p.smartIri, value)) })
}

private def asKnoraCardinalityResource(stmt: Statement): Option[Resource] =
def isKnoraCardinality(res: Resource): Boolean =
res.isAnon && res.hasProperty(OWL.onProperty) && res.objectRdfClass().contains(OWL.Restriction.toString)
stmt.getObject match
case res: Resource if isKnoraCardinality(res) => Some(res)
case _ => None

private def asKnoraCardinalityInfo(bNode: Resource): Either[String, KnoraCardinalityInfo] = {
val minMaxEither: Either[String, (Int, Option[Int])] = for {
max <- bNode.objectIntOption(OWL.maxCardinality)
min <- bNode.objectIntOption(OWL.minCardinality)
card <- bNode.objectIntOption(OWL.cardinality)
} yield (card.orElse(min).getOrElse(0), card.orElse(max))

for {
minMax <- minMaxEither
cardinality <- Cardinality.from.tupled.apply(minMax)
guiOrder <- bNode.objectIntOption(SalsahGui.External.GuiOrder)
} yield KnoraCardinalityInfo(cardinality, guiOrder)
}
}

object OntologyV2RequestParser {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,9 @@ object Cardinality {
val allCardinalities: Array[Cardinality] = Array(AtLeastOne, ExactlyOne, Unbounded, ZeroOrOne)

def fromString(str: String): Option[Cardinality] = allCardinalities.find(_.toString == str)

def from(min: Int, max: Option[Int]): Either[String, Cardinality] =
allCardinalities
.find(it => min == it.min && max == it.max)
.toRight(s"Invalid cardinality $min-${max.getOrElse("n")}, expected one of ${allCardinalities.mkString(", ")}")
}
Loading

0 comments on commit 1d69a9b

Please sign in to comment.