Skip to content

Commit

Permalink
refactor: Start replacing Json-LD parsing with using an RDF model (#3401
Browse files Browse the repository at this point in the history
)
  • Loading branch information
seakayone authored Oct 30, 2024
1 parent c5b0c45 commit 79e7194
Show file tree
Hide file tree
Showing 7 changed files with 429 additions and 109 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2836,7 +2836,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec {

val jsonLDEntity =
s"""{
| "@id" : "$resourceIri",
| "@id" : "$resourceIri",
| "@type" : "anything:Thing",
| "anything:hasOtherThingValue" : {
| "@id" : "$customValueIri",
Expand All @@ -2862,7 +2862,8 @@ class ValuesRouteV2E2ESpec extends E2ESpec {
HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity),
) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password))
val response: HttpResponse = singleAwaitingRequest(request)
assert(response.status == StatusCodes.OK, response.toString)
val bodyStr = Await.result(Unmarshal(response.entity).to[String], 3.seconds)
assert(response.status == StatusCodes.OK, bodyStr)
val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response)
val valueIri: IRI =
responseJsonDoc.body.requireStringWithValidation(JsonLDKeywords.ID, validationFun)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

package org.knora.webapi.messages.v2.responder.valuemessages

import zio.ZIO

import java.net.URI
Expand Down Expand Up @@ -45,6 +44,7 @@ import org.knora.webapi.slice.admin.api.model.Project
import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode
import org.knora.webapi.slice.admin.domain.model.Permission
import org.knora.webapi.slice.admin.domain.model.User
import org.knora.webapi.slice.common.KnoraApiValueModel
import org.knora.webapi.slice.resourceinfo.domain.InternalIri
import org.knora.webapi.slice.resourceinfo.domain.IriConverter
import org.knora.webapi.slice.resources.IiifImageRequestUrl
Expand Down Expand Up @@ -569,89 +569,89 @@ object CreateValueV2 {
ingestState: AssetIngestState,
jsonLdString: String,
requestingUser: User,
): ZIO[SipiService & StringFormatter & IriConverter & MessageRelay, Throwable, CreateValueV2] =
ZIO.serviceWithZIO[StringFormatter] { implicit stringFormatter =>
for {
// Get the IRI of the resource that the value is to be created in.
jsonLDDocument <- ZIO.attempt(JsonLDUtil.parseJsonLD(jsonLdString))
resourceIri <- jsonLDDocument.body.getRequiredIdValueAsKnoraDataIri
.mapError(BadRequestException(_))
.flatMap(RouteUtilZ.ensureIsKnoraResourceIri)

shortcode <- ZIO
.fromEither(resourceIri.getProjectShortcode)
.mapError(msg => NotFoundException(s"Shortcode not found. $msg"))

// Get the resource class.
resourceClassIri <-
jsonLDDocument.body.getRequiredTypeAsKnoraApiV2ComplexTypeIri.mapError(BadRequestException(_))

// Get the resource property and the value to be created.
createValue <-
jsonLDDocument.body.getRequiredResourcePropertyApiV2ComplexValue.mapError(BadRequestException(_)).flatMap {
case (propertyIri: SmartIri, jsonLdObject: JsonLDObject) =>
for {
fileInfo <- ValueContentV2.getFileInfo(shortcode, ingestState, jsonLdObject)
valueContent <- ValueContentV2.fromJsonLdObject(jsonLdObject, requestingUser, fileInfo)

// Get and validate the custom value IRI if provided.
maybeCustomValueIri <- jsonLdObject.getIdValueAsKnoraDataIri
.mapError(BadRequestException(_))
.mapAttempt { definedNewIri =>
definedNewIri.foreach(
stringFormatter.validateCustomValueIri(
_,
shortcode.value,
resourceIri.getResourceID.get,
),
): ZIO[SipiService & StringFormatter & IriConverter & MessageRelay, Throwable, CreateValueV2] = ZIO.scoped {
ZIO.serviceWithZIO[IriConverter] { converter =>
ZIO.serviceWithZIO[StringFormatter] { implicit sf =>
for {
// Get the IRI of the resource that the value is to be created in.
model <- KnoraApiValueModel.fromJsonLd(jsonLdString, converter).mapError(e => BadRequestException(e.msg))
shortcode <- ZIO
.fromEither(model.rootResourceIri.getProjectShortcode)
.mapError(msg => NotFoundException(s"Shortcode not found. $msg"))
resourceClassIri <- model.rootResourceClassIri.mapError {
case Some(e) => BadRequestException(e.msg)
case None => BadRequestException("No resource class found")
}

// Get the resource property and the value to be created.
jsonLDDocument <- ZIO.attempt(JsonLDUtil.parseJsonLD(jsonLdString))
createValue <-
jsonLDDocument.body.getRequiredResourcePropertyApiV2ComplexValue.mapError(BadRequestException(_)).flatMap {
case (propertyIri: SmartIri, jsonLdObject: JsonLDObject) =>
for {
fileInfo <- ValueContentV2.getFileInfo(shortcode, ingestState, jsonLdObject)
valueContent <- ValueContentV2.fromJsonLdObject(jsonLdObject, requestingUser, fileInfo)

// Get and validate the custom value IRI if provided.
maybeCustomValueIri <- jsonLdObject.getIdValueAsKnoraDataIri
.mapError(BadRequestException(_))
.mapAttempt { definedNewIri =>
definedNewIri.foreach(
sf.validateCustomValueIri(
_,
shortcode.value,
model.rootResourceIri.getResourceID.get,
),
)
definedNewIri
}

// Get the custom value UUID if provided.
maybeCustomUUID <- jsonLdObject.getUuid(ValueHasUUID).mapError(BadRequestException(_))

// Get the value's creation date.
// TODO: creationDate for values is a bug, and will not be supported in future. Use valueCreationDate instead.
maybeCreationDate <- ZIO.attempt(
jsonLdObject
.maybeDatatypeValueInObject(
key = ValueCreationDate,
expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri,
validationFun = (s, errorFun) =>
ValuesValidator.xsdDateTimeStampToInstant(s).getOrElse(errorFun),
)
definedNewIri
}

// Get the custom value UUID if provided.
maybeCustomUUID <- jsonLdObject.getUuid(ValueHasUUID).mapError(BadRequestException(_))

// Get the value's creation date.
// TODO: creationDate for values is a bug, and will not be supported in future. Use valueCreationDate instead.
maybeCreationDate <- ZIO.attempt(
jsonLdObject
.maybeDatatypeValueInObject(
key = ValueCreationDate,
expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri,
validationFun = (s, errorFun) =>
ValuesValidator.xsdDateTimeStampToInstant(s).getOrElse(errorFun),
)
.orElse(
jsonLdObject
.maybeDatatypeValueInObject(
key = CreationDate,
expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri,
validationFun = (s, errorFun) =>
ValuesValidator.xsdDateTimeStampToInstant(s).getOrElse(errorFun),
),
),
)

maybePermissions <-
ZIO.attempt {
val validationFun: (String, => Nothing) => String =
(s, errorFun) => Iri.toSparqlEncodedString(s).getOrElse(errorFun)
jsonLdObject.maybeStringWithValidation(HasPermissions, validationFun)
}
} yield CreateValueV2(
resourceIri = resourceIri.toString,
resourceClassIri = resourceClassIri,
propertyIri = propertyIri,
valueContent = valueContent,
valueIri = maybeCustomValueIri,
valueUUID = maybeCustomUUID,
valueCreationDate = maybeCreationDate,
permissions = maybePermissions,
ingestState = ingestState,
)
}
} yield createValue
.orElse(
jsonLdObject
.maybeDatatypeValueInObject(
key = CreationDate,
expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri,
validationFun = (s, errorFun) =>
ValuesValidator.xsdDateTimeStampToInstant(s).getOrElse(errorFun),
),
),
)

maybePermissions <-
ZIO.attempt {
val validationFun: (String, => Nothing) => String =
(s, errorFun) => Iri.toSparqlEncodedString(s).getOrElse(errorFun)
jsonLdObject.maybeStringWithValidation(HasPermissions, validationFun)
}
} yield CreateValueV2(
resourceIri = model.rootResourceIri.toString,
resourceClassIri = resourceClassIri,
propertyIri = propertyIri,
valueContent = valueContent,
valueIri = maybeCustomValueIri,
valueUUID = maybeCustomUUID,
valueCreationDate = maybeCreationDate,
permissions = maybePermissions,
ingestState = ingestState,
)
}
} yield createValue
}
}
}
}

/** A trait for classes representing information to be updated in a value. */
Expand Down
122 changes: 122 additions & 0 deletions webapi/src/main/scala/org/knora/webapi/slice/common/ModelOps.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* 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.common

import org.apache.jena.rdf.model.*
import org.apache.jena.riot.Lang
import org.apache.jena.riot.RDFDataMgr
import org.apache.jena.vocabulary.RDF
import zio.*

import java.io.ByteArrayInputStream
import java.nio.charset.StandardCharsets
import scala.util.Try

import org.knora.webapi.ApiV2Complex
import org.knora.webapi.messages.SmartIri
import org.knora.webapi.slice.common.ModelError.IsNoResourceIri
import org.knora.webapi.slice.common.ModelError.MoreThanOneRootResource
import org.knora.webapi.slice.common.ModelError.ParseError
import org.knora.webapi.slice.resourceinfo.domain.IriConverter

enum ModelError(val msg: String) {
case ParseError(override val msg: String) extends ModelError(msg)
case IsNoResourceIri(override val msg: String, iri: String) extends ModelError(msg)
case InvalidResourceClassIri(override val msg: String, iri: String) extends ModelError(msg)
case MoreThanOneRootResource(override val msg: String) extends ModelError(msg)
case NoRootResource(override val msg: String) extends ModelError(msg)
}
object ModelError {
def parseError(ex: Throwable): ParseError = ParseError(ex.getMessage)
def noResourceIri(iri: SmartIri): IsNoResourceIri =
IsNoResourceIri(s"This is not a resource IRI $iri", iri.toOntologySchema(ApiV2Complex).toIri)
def moreThanOneRootResource: MoreThanOneRootResource = MoreThanOneRootResource("More than one root resource found")
def noRootResource: NoRootResource = NoRootResource("No root resource found")
def invalidResourceClassIri(iri: SmartIri): InvalidResourceClassIri =
InvalidResourceClassIri("Invalid resource class IRI", iri.toIri)
}

/*
* The KnoraApiModel represents any incoming value models from our v2 API.
*/
final case class KnoraApiValueModel(model: Model, rootResourceIri: SmartIri, convert: IriConverter) { self =>
import ResourceOps.*
import StatementOps.*

def rootResource: Resource = model.getResource(rootResourceIri.toString)

def rootResourceClassIri: IO[Option[ModelError], SmartIri] = ZIO
.fromOption(rootResource.rdfsType())
.flatMap(convert.asSmartIri(_).mapError(ModelError.parseError).asSomeError)
.filterOrElseWith(iri => iri.isKnoraEntityIri && iri.isApiV2ComplexSchema)(iri =>
ZIO.fail(ModelError.invalidResourceClassIri(iri)).asSomeError,
)
}

object KnoraApiValueModel { self =>
import StatementOps.*

// available for ease of use in tests
def fromJsonLd(str: String): ZIO[Scope & IriConverter, ModelError, KnoraApiValueModel] =
ZIO.service[IriConverter].flatMap(self.fromJsonLd(str, _))

def fromJsonLd(str: String, converter: IriConverter): ZIO[Scope & IriConverter, ModelError, KnoraApiValueModel] =
for {
model <- ModelOps.fromJsonLd(str)
root <- getRootResourceIri(model, converter)
} yield KnoraApiValueModel(model, root, converter)

private def getRootResourceIri(model: Model, convert: IriConverter): IO[ModelError, SmartIri] =
val iter = model.listStatements()
var objSeen = Set.empty[String]
var subSeen = Set.empty[String]
while (iter.hasNext) {
val stmt = iter.nextStatement()
val _ = stmt.objectUri().foreach(iri => objSeen += iri)
val _ = stmt.subjectUri().foreach(iri => subSeen += iri)
}
val result: IO[ModelError, SmartIri] = (subSeen -- objSeen) match {
case result if result.size == 1 =>
convert
.asSmartIri(result.head)
.mapError(ModelError.parseError)
.filterOrElseWith(_.isKnoraResourceIri)(iri => ZIO.fail(ModelError.noResourceIri(iri)))
case result if result.isEmpty => ZIO.fail(ModelError.noRootResource)
case _ => ZIO.fail(ModelError.moreThanOneRootResource)
}
result
}

object ResourceOps {
extension (res: Resource) {
def property(p: Property): Option[Statement] = Option(res.getProperty(p))
def rdfsType(): Option[String] = Option(res.getPropertyResourceValue(RDF.`type`)).flatMap(_.uri)
def uri: Option[String] = Option(res.getURI)
}
}

object StatementOps {
extension (stmt: Statement) {
def subjectUri(): Option[String] = Option(stmt.getSubject.getURI)
def objectUri(): Option[String] = Try(stmt.getObject.asResource()).toOption.flatMap(r => Option(r.getURI))
}
}

object ModelOps { self =>

def fromJsonLd(str: String): ZIO[Scope, ParseError, Model] = from(str, Lang.JSONLD)

private val createModel =
ZIO.acquireRelease(ZIO.succeed(ModelFactory.createDefaultModel()))(m => ZIO.succeed(m.close()))

def from(str: String, lang: Lang): ZIO[Scope, ParseError, Model] =
for {
m <- createModel
_ <- ZIO
.attempt(RDFDataMgr.read(m, ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8)), lang))
.mapError(ModelError.parseError)
} yield m
}
Loading

0 comments on commit 79e7194

Please sign in to comment.