diff --git a/integration/src/test/scala/org/knora/webapi/E2ESpec.scala b/integration/src/test/scala/org/knora/webapi/E2ESpec.scala index 3e57ac30f1..35e64908ec 100644 --- a/integration/src/test/scala/org/knora/webapi/E2ESpec.scala +++ b/integration/src/test/scala/org/knora/webapi/E2ESpec.scala @@ -120,7 +120,7 @@ abstract class E2ESpec protected def singleAwaitingRequest( request: HttpRequest, timeout: Option[zio.Duration] = None, - printFailure: Boolean = false, + printFailure: Boolean = true, ): HttpResponse = UnsafeZioRun.runOrThrow( ZIO.serviceWithZIO[TestClientService](_.singleAwaitingRequest(request, timeout, printFailure)), diff --git a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala index 507faed8ec..9167c13a61 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -218,7 +218,7 @@ object LayersTest { TapirToPekkoInterpreter.layer, TestClientService.layer, TriplestoreServiceLive.layer, - ValuesResponderV2Live.layer, + ValuesResponderV2.layer, ) private val commonLayersForAllIntegrationTests2 = diff --git a/integration/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala b/integration/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala index d147b9296d..080152eacc 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala @@ -30,7 +30,6 @@ import org.knora.webapi.messages.store.triplestoremessages.* import org.knora.webapi.messages.twirl.queries.sparql import org.knora.webapi.messages.util.CalendarNameGregorian import org.knora.webapi.messages.util.DatePrecisionYear -import org.knora.webapi.messages.util.KnoraSystemInstances import org.knora.webapi.messages.util.PermissionUtilADM import org.knora.webapi.messages.v2.responder.SuccessResponseV2 import org.knora.webapi.messages.v2.responder.resourcemessages.* @@ -537,10 +536,7 @@ class ResourcesResponderV2Spec extends CoreSpec with ImplicitSender { override implicit val timeout: FiniteDuration = 30.seconds "Load test data" in { - appActor ! GetMappingRequestV2( - mappingIri = "http://rdfh.ch/standoff/mappings/StandardMapping", - requestingUser = KnoraSystemInstances.Users.SystemUser, - ) + appActor ! GetMappingRequestV2("http://rdfh.ch/standoff/mappings/StandardMapping") expectMsgPF(timeout) { case mappingResponse: GetMappingResponseV2 => standardMapping = Some(mappingResponse.mapping) diff --git a/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala b/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala index 013ea518e8..ace062703e 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala @@ -24,7 +24,6 @@ import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.store.triplestoremessages.* import org.knora.webapi.messages.util.CalendarNameGregorian import org.knora.webapi.messages.util.DatePrecisionYear -import org.knora.webapi.messages.util.KnoraSystemInstances import org.knora.webapi.messages.util.PermissionUtilADM import org.knora.webapi.messages.util.search.gravsearch.GravsearchParser import org.knora.webapi.messages.v2.responder.resourcemessages.* @@ -400,10 +399,7 @@ class ValuesResponderV2Spec extends CoreSpec with ImplicitSender { } "Load test data" in { - appActor ! GetMappingRequestV2( - mappingIri = "http://rdfh.ch/standoff/mappings/StandardMapping", - requestingUser = KnoraSystemInstances.Users.SystemUser, - ) + appActor ! GetMappingRequestV2("http://rdfh.ch/standoff/mappings/StandardMapping") expectMsgPF(timeout) { case mappingResponse: GetMappingResponseV2 => standardMapping = Some(mappingResponse.mapping) diff --git a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala index 05827baabf..99a1a87585 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -196,7 +196,7 @@ object LayersLive { StringFormatter.live, TapirToPekkoInterpreter.layer, TriplestoreServiceLive.layer, - ValuesResponderV2Live.layer, + ValuesResponderV2.layer, org.knora.webapi.core.ActorSystem.layer, // ZLayer.Debug.mermaid, ) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala b/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala index 843a7e854b..3decdb29bb 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/ValuesValidator.scala @@ -79,7 +79,12 @@ object ValuesValidator { * @return [[Option]] of [[Instant]]. */ def xsdDateTimeStampToInstant(s: String): Option[Instant] = - Try(Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(s))).toOption + parseXsdDateTimeStamp(s).toOption + + def parseXsdDateTimeStamp(s: String): Either[String, Instant] = + Try(Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(s))).toEither.left.map(e => + s"Invalid xsd:dateTimeStamp value '$s': ${e.getMessage}", + ) /** * Parses a DSP ARK timestamp. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/CalendarDateUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/CalendarDateUtilV2.scala index 4c84a2be11..ed2a683dec 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/CalendarDateUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/CalendarDateUtilV2.scala @@ -29,10 +29,13 @@ object DateEraV2 { * @return a [[DateEraV2]] representing the era. */ def parse(eraStr: String, errorFun: => Nothing): DateEraV2 = + fromString(eraStr).getOrElse(errorFun) + + def fromString(eraStr: String): Either[String, DateEraV2] = eraStr match { - case StringFormatter.Era_AD | StringFormatter.Era_CE => DateEraCE - case StringFormatter.Era_BC | StringFormatter.Era_BCE => DateEraBCE - case _ => errorFun + case StringFormatter.Era_AD | StringFormatter.Era_CE => Right(DateEraCE) + case StringFormatter.Era_BC | StringFormatter.Era_BCE => Right(DateEraBCE) + case _ => Left(s"Invalid era: $eraStr") } } @@ -110,11 +113,14 @@ object CalendarNameV2 { * @return a [[CalendarNameV2]] representing the name of the calendar. */ def parse(calendarNameStr: String, errorFun: => Nothing): CalendarNameV2 = + fromString(calendarNameStr).getOrElse(errorFun) + + def fromString(calendarNameStr: String): Either[String, CalendarNameV2] = calendarNameStr match { - case StringFormatter.CalendarGregorian => CalendarNameGregorian - case StringFormatter.CalendarJulian => CalendarNameJulian - case StringFormatter.CalendarIslamic => CalendarNameIslamic - case _ => errorFun + case StringFormatter.CalendarGregorian => Right(CalendarNameGregorian) + case StringFormatter.CalendarJulian => Right(CalendarNameJulian) + case StringFormatter.CalendarIslamic => Right(CalendarNameIslamic) + case _ => Left(s"Invalid calendar name: $calendarNameStr") } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala index 83a0438577..a93f3b63d5 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala @@ -1674,7 +1674,7 @@ final case class ConstructResponseUtilV2Live( // get all the mappings val mappingResponsesFuture = mappingIris.map { (mappingIri: IRI) => - messageRelay.ask[GetMappingResponseV2](GetMappingRequestV2(mappingIri, requestingUser)) + messageRelay.ask[GetMappingResponseV2](GetMappingRequestV2(mappingIri)) } for { diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/standoff/StandoffTagUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/standoff/StandoffTagUtilV2.scala index 5522560476..d86432af6b 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/standoff/StandoffTagUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/standoff/StandoffTagUtilV2.scala @@ -108,7 +108,6 @@ final case class StandoffTagUtilV2Live(messageRelay: MessageRelay)(implicit val StandoffEntityInfoGetRequestV2( standoffClassIris = standoffClassIris, standoffPropertyIris = standoffPropertyIris, - requestingUser = requestingUser, ), ) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala index fd723ce4c1..4cc62a0338 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala @@ -1127,9 +1127,8 @@ case class EntityInfoGetResponseV2( * @param requestingUser the user making the request. */ case class StandoffEntityInfoGetRequestV2( - standoffClassIris: Set[SmartIri] = Set.empty[SmartIri], - standoffPropertyIris: Set[SmartIri] = Set.empty[SmartIri], - requestingUser: User, + standoffClassIris: Set[SmartIri] = Set.empty, + standoffPropertyIris: Set[SmartIri] = Set.empty, ) extends OntologiesResponderRequestV2 /** diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/standoffmessages/StandoffMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/standoffmessages/StandoffMessagesV2.scala index 257f1550eb..37dc4bf57f 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/standoffmessages/StandoffMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/standoffmessages/StandoffMessagesV2.scala @@ -158,7 +158,7 @@ case class CreateMappingResponseV2(mappingIri: IRI, label: String, projectIri: S * @param mappingIri the IRI of the mapping. * @param requestingUser the the user making the request. */ -case class GetMappingRequestV2(mappingIri: IRI, requestingUser: User) extends StandoffResponderRequestV2 +case class GetMappingRequestV2(mappingIri: IRI) extends StandoffResponderRequestV2 /** * Represents a response to a [[GetMappingRequestV2]]. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala index 5fe082a04e..772fa99061 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala @@ -4,11 +4,16 @@ */ package org.knora.webapi.messages.v2.responder.valuemessages + +import org.apache.jena.rdf.model.Resource +import zio.IO import zio.ZIO import java.net.URI import java.time.Instant import java.util.UUID +import scala.language.implicitConversions +import scala.util.Try import dsp.errors.AssertionException import dsp.errors.BadRequestException @@ -35,6 +40,7 @@ import org.knora.webapi.messages.util.standoff.XMLUtil import org.knora.webapi.messages.v2.responder.* import org.knora.webapi.messages.v2.responder.resourcemessages.ReadResourceV2 import org.knora.webapi.messages.v2.responder.standoffmessages.* +import org.knora.webapi.messages.v2.responder.valuemessages.ValueContentV2.FileInfo import org.knora.webapi.routing.RouteUtilV2 import org.knora.webapi.routing.RouteUtilZ import org.knora.webapi.routing.v2.AssetIngestState @@ -44,7 +50,9 @@ 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.common.KnoraApiCreateValueModel +import org.knora.webapi.slice.common.jena.JenaConversions.given +import org.knora.webapi.slice.common.jena.ResourceOps.* import org.knora.webapi.slice.resourceinfo.domain.InternalIri import org.knora.webapi.slice.resourceinfo.domain.IriConverter import org.knora.webapi.slice.resources.IiifImageRequestUrl @@ -52,6 +60,12 @@ import org.knora.webapi.store.iiif.api.FileMetadataSipiResponse import org.knora.webapi.store.iiif.api.SipiService import org.knora.webapi.util.WithAsIs +private def objectCommentOption(r: Resource): Either[String, Option[String]] = + r.objectStringOption(ValueHasComment) flatMap { + case Some(str) => Iri.toSparqlEncodedString(str).toRight(s"Invalid comment: $str").map(Some(_)) + case None => Right(None) + } + /** * Represents a successful response to a create value Request. * @@ -562,95 +576,28 @@ object CreateValueV2 { * * @param ingestState indicates the state of the file, either ingested or in temp folder * @param jsonLdString JSON-LD input as String. - * @param requestingUser the user making the request. * @return a case class instance representing the input. */ def fromJsonLd( ingestState: AssetIngestState, jsonLdString: String, - requestingUser: User, - ): 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), - ) - .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 - } - } + ): ZIO[SipiService & IriConverter & MessageRelay, Throwable, CreateValueV2] = ZIO.scoped { + for { + converter <- ZIO.service[IriConverter] + model <- KnoraApiCreateValueModel.fromJsonLd(jsonLdString, converter).mapError(BadRequestException(_)) + fileInfo <- ValueContentV2.fileInfoFromExternal(model.valueFileValueFilename, ingestState, model.shortcode) + valueContent <- model.getValueContent(fileInfo).mapError(BadRequestException(_)) + } yield CreateValueV2( + resourceIri = model.resourceIri.toString, + resourceClassIri = model.resourceClassIri.smartIri, + propertyIri = model.valuePropertyIri.smartIri, + valueContent = valueContent, + valueIri = model.valueIri.map(_.smartIri), + valueUUID = model.valueUuid, + valueCreationDate = model.valueCreationDate, + permissions = model.valuePermissions, + ingestState = ingestState, + ) } } @@ -1090,23 +1037,25 @@ object ValueContentV2 { ingestState: AssetIngestState, jsonLd: JsonLDObject, ): ZIO[SipiService, Throwable, Option[FileInfo]] = - ZIO - .fromEither(jsonLd.getString(FileValueHasFilename)) - .orElse(ZIO.none) - .flatMap(fileMaybe => - (fileMaybe, ingestState) match { - case (Some(filename), AssetIngested) => fileInfoFromDspIngest(shortcode, filename).asSome - case (Some(filename), AssetInTemp) => fileInfoFromSipi(filename).asSome - case (None, _) => ZIO.none - }, - ) + val filenameMaybe = jsonLd.getString(FileValueHasFilename).toOption.flatten + fileInfoFromExternal(filenameMaybe, ingestState, shortcode) + + def fileInfoFromExternal( + filenameMaybe: Option[String], + state: AssetIngestState, + shortcode: Shortcode, + ): ZIO[SipiService, Throwable, Option[FileInfo]] = + (filenameMaybe, state) match + case (None, _) => ZIO.none + case (Some(filename), AssetIngested) => fileInfoFromDspIngest(shortcode, filename).asSome + case (Some(filename), AssetInTemp) => fileInfoFromSipi(filename).asSome private def fileInfoFromSipi(filename: String) = ZIO.serviceWithZIO[SipiService]( _.getFileMetadataFromSipiTemp(filename) .mapBoth( { - case NotFoundException(msg) => + case NotFoundException(_) => NotFoundException( s"Asset '$filename' not found in Sipi temp, when ingested with dsp-ingest you want to add the 'X-Asset-Ingested' header.", ) @@ -1123,7 +1072,7 @@ object ValueContentV2 { .fromEither(AssetId.from(filename.substring(0, filename.indexOf('.')))) .mapError(msg => BadRequestException(s"Invalid value for 'fileValueHasFilename': $msg")) meta <- sipiService.getFileMetadataFromDspIngest(shortcode, assetId).mapError { - case NotFoundException(msg) => + case NotFoundException(_) => NotFoundException( s"Asset '$filename' not found in dsp-ingest, when ingested to Sipi temp you want to remove the 'X-Asset-Ingested' header.", ) @@ -1349,6 +1298,51 @@ object DateValueContentV2 { valueHasCalendar = calendarName, comment, ) + + def from(r: Resource): Either[String, DateValueContentV2] = { + def objectEraOption(resource: Resource, property: String) = for { + eraStr <- resource.objectStringOption(property) + era <- eraStr match + case Some(e) => DateEraV2.fromString(e).map(Some(_)) + case None => Right(None) + } yield era + for { + startYear <- r.objectInt(DateValueHasStartYear) + startMonth <- r.objectIntOption(DateValueHasStartMonth) + startDay <- r.objectIntOption(DateValueHasStartDay) + startEra <- objectEraOption(r, DateValueHasStartEra) + + endYear <- r.objectInt(DateValueHasEndYear) + endMonth <- r.objectIntOption(DateValueHasEndMonth) + endDay <- r.objectIntOption(DateValueHasEndDay) + endEra <- objectEraOption(r, DateValueHasEndEra) + + calendarName <- r.objectString(DateValueHasCalendar).flatMap(CalendarNameV2.fromString) + + // validate the combination of start/end dates and calendarName + _ <- if (startMonth.isEmpty && startDay.isDefined) Left(s"Start day defined, missing start month") else Right(()) + _ <- if (endMonth.isEmpty && endDay.isDefined) Left(s"End day defined, missing end month") else Right(()) + _ <- if (calendarName.isInstanceOf[CalendarNameGregorianOrJulian] && (startEra.isEmpty || endEra.isEmpty)) + Left(s"Era is required in calendar $calendarName") + else Right(()) + + startDate = CalendarDateV2(calendarName, startYear, startMonth, startDay, startEra) + endDate = CalendarDateV2(calendarName, endYear, endMonth, endDay, endEra) + dateRange = CalendarDateRangeV2(startDate, endDate) + startEnd <- Try(dateRange.toJulianDayRange).toEither.left.map(_.getMessage) + (startJdn, endJdn) = startEnd + + comment <- objectCommentOption(r) + } yield DateValueContentV2( + ApiV2Complex, + startJdn, + endJdn, + startDate.precision, + endDate.precision, + calendarName, + comment, + ) + } } /** @@ -1674,21 +1668,19 @@ object TextValueContentV2 { maybeTextValueAsXml: Option[String], maybeValueHasLanguage: Option[IRI], maybeMappingResponse: Option[GetMappingResponseV2], - jsonLdObject: JsonLDObject, + comment: Option[String], ) = (maybeValueAsString, maybeTextValueAsXml, maybeMappingResponse) match { case (Some(valueAsString), None, None) => // Text without standoff. - JsonLDUtil - .getComment(jsonLdObject) - .map(comment => - TextValueContentV2( - ontologySchema = ApiV2Complex, - maybeValueHasString = Some(valueAsString), - valueHasLanguage = maybeValueHasLanguage, - textValueType = TextValueType.UnformattedText, - comment = comment, - ), - ) + ZIO.succeed( + TextValueContentV2( + ontologySchema = ApiV2Complex, + maybeValueHasString = Some(valueAsString), + valueHasLanguage = maybeValueHasLanguage, + textValueType = TextValueType.UnformattedText, + comment = comment, + ), + ) case (None, Some(textValueAsXml), Some(mappingResponse)) => for { @@ -1706,8 +1698,7 @@ object TextValueContentV2 { } else { TextValueType.CustomFormattedText(InternalIri(mappingResponse.mappingIri)) } - text <- RouteUtilZ.toSparqlEncodedString(textWithStandoffTags.text, "Text value contains invalid characters") - comment <- JsonLDUtil.getComment(jsonLdObject) + text <- RouteUtilZ.toSparqlEncodedString(textWithStandoffTags.text, "Text value contains invalid characters") } yield TextValueContentV2( ontologySchema = ApiV2Complex, maybeValueHasString = Some(text), @@ -1747,23 +1738,41 @@ object TextValueContentV2 { maybeMappingResponse <- getIriFromObject(jsonLdObject, TextValueHasMapping).flatMap(mappingIriOption => ZIO.foreach(mappingIriOption) { mappingIri => - ZIO.serviceWithZIO[MessageRelay]( - _.ask[GetMappingResponseV2](GetMappingRequestV2(mappingIri, requestingUser)), - ) + ZIO.serviceWithZIO[MessageRelay](_.ask[GetMappingResponseV2](GetMappingRequestV2(mappingIri))) }, ) - _ <- JsonLDUtil.getComment(jsonLdObject) + comment <- JsonLDUtil.getComment(jsonLdObject) // Did the client submit text with or without standoff markup? - textValue <- getTextValue( - maybeValueAsString, - maybeTextValueAsXml, - maybeValueHasLanguage, - maybeMappingResponse, - jsonLdObject, - ) + textValue <- + getTextValue(maybeValueAsString, maybeTextValueAsXml, maybeValueHasLanguage, maybeMappingResponse, comment) } yield textValue + + private def objectSparqlStringOption(r: Resource, property: String) = for { + str <- r.objectStringOption(property) + iri <- str match + case Some(s) => Right(Iri.toSparqlEncodedString(s)) + case None => Right(None) + } yield iri + + def from(r: Resource): ZIO[MessageRelay, IRI, TextValueContentV2] = for { + messageRelay <- ZIO.service[MessageRelay] + maybeValueAsString <- ZIO.fromEither(objectSparqlStringOption(r, ValueAsString)) + maybeValueHasLanguage <- ZIO.fromEither(objectSparqlStringOption(r, TextValueHasLanguage)) + maybeTextValueAsXml <- ZIO.fromEither(r.objectStringOption(TextValueAsXml)) + comment <- ZIO.fromEither(objectCommentOption(r)) + mappingIriOption <- ZIO.fromEither(r.objectUriOption(TextValueHasMapping)) + maybeMappingResponse <- ZIO + .foreach(mappingIriOption) { mappingIri => + messageRelay.ask[GetMappingResponseV2](GetMappingRequestV2(mappingIri)) + } + .mapError(_.getMessage) + textValue <- + getTextValue(maybeValueAsString, maybeTextValueAsXml, maybeValueHasLanguage, maybeMappingResponse, comment) + .mapError(_.getMessage) + + } yield textValue } /** @@ -1837,9 +1846,16 @@ object IntegerValueContentV2 { .mapError(BadRequestException(_)) comment <- JsonLDUtil.getComment(jsonLDObject) } yield IntegerValueContentV2(ApiV2Complex, intValue, comment) + + def from(r: Resource): Either[String, IntegerValueContentV2] = + for { + intValue <- r.objectInt(IntValueAsInt) + comment <- objectCommentOption(r) + } yield IntegerValueContentV2(ApiV2Complex, intValue, comment) } /** + * import org.knora.webapi.slice.common.ResourceOps.* * Represents a Knora decimal value. * * @param valueHasDecimal the decimal value. @@ -1922,6 +1938,12 @@ object DecimalValueContentV2 { comment <- JsonLDUtil.getComment(jsonLdObject) } yield DecimalValueContentV2(ApiV2Complex, decimalValue, comment) } + + def from(r: Resource): Either[String, DecimalValueContentV2] = + for { + decimalValue <- r.objectBigDecimal(DecimalValueAsDecimal) + comment <- objectCommentOption(r) + } yield DecimalValueContentV2(ApiV2Complex, decimalValue, comment) } /** @@ -1993,6 +2015,11 @@ object BooleanValueContentV2 { .mapError(BadRequestException(_)) comment <- JsonLDUtil.getComment(jsonLdObject) } yield BooleanValueContentV2(ApiV2Complex, booleanValue, comment) + + def from(r: Resource): Either[String, BooleanValueContentV2] = for { + bool <- r.objectBoolean(BooleanValueAsBoolean) + comment <- objectCommentOption(r) + } yield BooleanValueContentV2(ApiV2Complex, bool, comment) } /** @@ -2072,6 +2099,12 @@ object GeomValueContentV2 { ) comment <- JsonLDUtil.getComment(jsonLDObject) } yield GeomValueContentV2(ontologySchema = ApiV2Complex, geometryValueAsGeometry, comment) + + def from(r: Resource): Either[String, GeomValueContentV2] = for { + geomStr <- r.objectString(GeometryValueAsGeometry) + geom <- ValuesValidator.validateGeometryString(geomStr).toRight(s"Invalid geometry string: $geomStr") + comment <- objectCommentOption(r) + } yield GeomValueContentV2(ApiV2Complex, geom, comment) } /** @@ -2184,6 +2217,13 @@ object IntervalValueContentV2 { comment <- JsonLDUtil.getComment(jsonLDObject) } yield IntervalValueContentV2(ApiV2Complex, intervalValueHasStart, intervalValueHasEnd, comment) } + + def from(r: Resource): Either[String, IntervalValueContentV2] = for { + intervalValueHasStart <- r.objectBigDecimal(IntervalValueHasStart) + intervalValueHasEnd <- r.objectBigDecimal(IntervalValueHasEnd) + comment <- objectCommentOption(r) + } yield IntervalValueContentV2(ApiV2Complex, intervalValueHasStart, intervalValueHasEnd, comment) + } /** @@ -2277,6 +2317,11 @@ object TimeValueContentV2 { comment <- JsonLDUtil.getComment(jsonLDObject) } yield TimeValueContentV2(ApiV2Complex, valueHasTimeStamp, comment) } + + def from(r: Resource): Either[String, TimeValueContentV2] = for { + timeStamp <- r.objectInstant(TimeValueAsTimeStamp) + comment <- objectCommentOption(r) + } yield TimeValueContentV2(ApiV2Complex, timeStamp, comment) } /** @@ -2376,6 +2421,14 @@ object HierarchicalListValueContentV2 { comment <- JsonLDUtil.getComment(jsonLDObject) } yield HierarchicalListValueContentV2(ApiV2Complex, listValueAsListNode.toString, None, comment) } + + def from(r: Resource, converter: IriConverter): IO[String, HierarchicalListValueContentV2] = for { + comment <- ZIO.fromEither(objectCommentOption(r)) + listNode <- ZIO.fromEither(r.objectUri(ListValueAsListNode)) + _ <- ZIO + .fail(s"List node IRI <$listNode> is not a Knora data IRI") + .unlessZIO(converter.isKnoraDataIri(listNode).mapError(_.getMessage)) + } yield HierarchicalListValueContentV2(ApiV2Complex, listNode, None, comment) } /** @@ -2454,6 +2507,11 @@ object ColorValueContentV2 { } comment <- JsonLDUtil.getComment(jsonLDObject) } yield ColorValueContentV2(ApiV2Complex, colorValueAsColor, comment) + + def from(r: Resource): Either[IRI, ColorValueContentV2] = for { + color <- r.objectString(ColorValueAsColor) + comment <- objectCommentOption(r) + } yield ColorValueContentV2(ApiV2Complex, color, comment) } /** @@ -2540,6 +2598,11 @@ object UriValueContentV2 { comment <- JsonLDUtil.getComment(jsonLDObject) } yield UriValueContentV2(ApiV2Complex, uriValueAsUri, comment) } + + def from(r: Resource): Either[String, UriValueContentV2] = for { + uri <- r.objectDataType(UriValueAsUri, OntologyConstants.Xsd.Uri) + comment <- objectCommentOption(r) + } yield UriValueContentV2(ApiV2Complex, uri, comment) } /** @@ -2627,6 +2690,11 @@ object GeonameValueContentV2 { } comment <- JsonLDUtil.getComment(jsonLDObject) } yield GeonameValueContentV2(ApiV2Complex, geonameValueAsGeonameCode, comment) + + def from(r: Resource): Either[String, GeonameValueContentV2] = for { + geonameCode <- r.objectString(GeonameValueAsGeonameCode) + comment <- objectCommentOption(r) + } yield GeonameValueContentV2(ApiV2Complex, geonameCode, comment) } /** @@ -2671,8 +2739,8 @@ sealed trait FileValueContentV2 extends ValueContentV2 { * Represents image file metadata. * * @param fileValue the basic metadata about the file value. - * @param dimX the with of the the image in pixels. - * @param dimY the height of the the image in pixels. + * @param dimX the width of the image in pixels. + * @param dimY the height of the image in pixels. * @param comment a comment on this `StillImageFileValueContentV2`, if any. */ case class StillImageFileValueContentV2( @@ -2763,14 +2831,24 @@ object StillImageFileValueContentV2 { dimY = metadata.height.getOrElse(0), comment = comment, ) + + def from(r: Resource, fileInfo: FileInfo): Either[String, StillImageFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = fileInfo.metadata + fileValue = FileValueV2(fileInfo.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + } yield StillImageFileValueContentV2( + ApiV2Complex, + fileValue, + meta.width.getOrElse(0), + meta.height.getOrElse(0), + comment, + ) } /** * Represents the external image file metadata. * * @param fileValue the basic metadata about the file value. - * @param dimX the with of the the image in pixels. - * @param dimY the height of the the image in pixels. * @param comment a comment on this `StillImageFileValueContentV2`, if any. */ case class StillImageExternalFileValueContentV2( @@ -2786,7 +2864,7 @@ case class StillImageExternalFileValueContentV2( override def valueHasString: String = fileValue.internalFilename - override def toOntologySchema(targetSchema: OntologySchema) = + override def toOntologySchema(targetSchema: OntologySchema): StillImageExternalFileValueContentV2 = copy(ontologySchema = targetSchema) def makeFileUrl: String = externalUrl.value.toString @@ -2866,6 +2944,13 @@ object StillImageExternalFileValueContentV2 { externalUrl = externalUrl, comment = comment, ) + + def from(r: Resource): Either[String, StillImageExternalFileValueContentV2] = for { + externalUrlStr <- r.objectString(StillImageFileValueHasExternalUrl) + iifUrl <- IiifImageRequestUrl.from(externalUrlStr) + comment <- objectCommentOption(r) + fileValue = FileValueV2("internalFilename", "internalMimeType", Some("originalFilename"), Some("originalMimeType")) + } yield StillImageExternalFileValueContentV2(ApiV2Complex, fileValue, iifUrl, comment) } /** @@ -2873,8 +2958,8 @@ object StillImageExternalFileValueContentV2 { * * @param fileValue the basic metadata about the file value. * @param pageCount the number of pages in the document. - * @param dimX the with of the the document in pixels. - * @param dimY the height of the the document in pixels. + * @param dimX the width of the document in pixels. + * @param dimY the height of the document in pixels. * @param comment a comment on this `DocumentFileValueContentV2`, if any. */ case class DocumentFileValueContentV2( @@ -3017,6 +3102,12 @@ object DocumentFileValueContentV2 { dimY = metadata.height, comment, ) + + def from(r: Resource, info: FileInfo): Either[String, DocumentFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = info.metadata + fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + } yield DocumentFileValueContentV2(ApiV2Complex, fileValue, meta.numpages, meta.width, meta.height, comment) } /** @@ -3035,6 +3126,12 @@ object ArchiveFileValueContentV2 { FileValueV2(internalFilename, metadata.internalMimeType, metadata.originalFilename, metadata.originalMimeType), comment, ) + + def from(r: Resource, info: FileInfo): Either[String, ArchiveFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = info.metadata + fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + } yield ArchiveFileValueContentV2(ApiV2Complex, fileValue, comment) } /** @@ -3111,6 +3208,12 @@ object TextFileValueContentV2 { FileValueV2(internalFilename, metadata.internalMimeType, metadata.originalFilename, metadata.originalMimeType), comment, ) + + def from(r: Resource, info: FileInfo): Either[String, TextFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = info.metadata + fileValue = FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType) + } yield TextFileValueContentV2(ApiV2Complex, fileValue, comment) } /** @@ -3188,6 +3291,15 @@ object AudioFileValueContentV2 { FileValueV2(internalFilename, metadata.internalMimeType, metadata.originalFilename, metadata.originalMimeType), comment, ) + + def from(r: Resource, info: FileInfo): Either[String, AudioFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = info.metadata + } yield AudioFileValueContentV2( + ApiV2Complex, + FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType), + comment, + ) } /** @@ -3267,6 +3379,15 @@ object MovingImageFileValueContentV2 { FileValueV2(internalFilename, metadata.internalMimeType, metadata.originalFilename, metadata.originalMimeType), comment, ) + + def from(r: Resource, info: FileInfo): Either[String, MovingImageFileValueContentV2] = for { + comment <- objectCommentOption(r) + meta = info.metadata + } yield MovingImageFileValueContentV2( + ApiV2Complex, + FileValueV2(info.filename, meta.internalMimeType, meta.originalFilename, meta.originalMimeType), + comment, + ) } /** @@ -3401,6 +3522,15 @@ object LinkValueContentV2 { comment <- JsonLDUtil.getComment(jsonLDObject) } yield LinkValueContentV2(ApiV2Complex, referredResourceIri = targetIri.toString, comment = comment) } + + def from(r: Resource, converter: IriConverter): IO[String, LinkValueContentV2] = + for { + targetIri <- ZIO.fromEither(r.objectUri(LinkValueHasTargetIri)) + comment <- ZIO.fromEither(objectCommentOption(r)) + _ <- ZIO + .fail(s"Link target IRI <${targetIri}> is not a Knora data IRI") + .unlessZIO(converter.isKnoraDataIri(targetIri).mapError(_.getMessage)) + } yield LinkValueContentV2(ApiV2Complex, referredResourceIri = targetIri, comment = comment) } /** diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index 1f75940531..f0917addd3 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -93,7 +93,7 @@ final case class OntologyResponderV2( override def handle(msg: ResponderRequest): Task[Any] = msg match { case EntityInfoGetRequestV2(classIris, propertyIris, requestingUser) => getEntityInfoResponseV2(classIris, propertyIris, requestingUser) - case StandoffEntityInfoGetRequestV2(standoffClassIris, standoffPropertyIris, _) => + case StandoffEntityInfoGetRequestV2(standoffClassIris, standoffPropertyIris) => getStandoffEntityInfoResponseV2(standoffClassIris, standoffPropertyIris) case StandoffClassesWithDataTypeGetRequestV2(_) => getStandoffStandoffClassesWithDataTypeV2 diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala index 8b3cbaf1b4..e3ae11382c 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala @@ -961,7 +961,7 @@ final case class ResourcesResponderV2( mappingToBeApplied = mappingIri.getOrElse(OntologyConstants.KnoraBase.TEIMapping) // get mapping to convert standoff markup to TEI/XML - teiMapping <- messageRelay.ask[GetMappingResponseV2](GetMappingRequestV2(mappingToBeApplied, requestingUser)) + teiMapping <- messageRelay.ask[GetMappingResponseV2](GetMappingRequestV2(mappingToBeApplied)) // get XSLT from mapping for the TEI body bodyXslt <- teiMapping.mappingIri match { diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala index b901870fa8..31f5b15a2f 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala @@ -91,8 +91,7 @@ final case class StandoffResponderV2( requestingUser, uuid, ) - case GetMappingRequestV2(mappingIri, requestingUser) => - getMappingV2(mappingIri, requestingUser) + case GetMappingRequestV2(mappingIri) => getMappingV2(mappingIri) case GetXSLTransformationRequestV2(xsltTextReprIri, requestingUser) => getXSLTransformation(xsltTextReprIri, requestingUser) case other => Responder.handleUnexpectedMessage(other, this.getClass.getName) @@ -432,7 +431,7 @@ final case class StandoffResponderV2( // checks if the standoff classes exist in the ontology // checks if the standoff properties exist in the ontology // checks if the attributes defined for XML elements have cardinalities for the standoff properties defined on the standoff class - _ <- getStandoffEntitiesFromMappingV2(mappingXMLToStandoff, requestingUser) + _ <- getStandoffEntitiesFromMappingV2(mappingXMLToStandoff) // check if the mapping IRI already exists getExistingMappingSparql = sparql.v2.txt.getMapping(mappingIri) @@ -667,21 +666,15 @@ final case class StandoffResponderV2( * Gets a mapping either from the cache or by making a request to the triplestore. * * @param mappingIri the IRI of the mapping to retrieve. - * @param requestingUser the user making the request. * @return a [[MappingXMLtoStandoff]]. */ - private def getMappingV2( - mappingIri: IRI, - requestingUser: User, - ): Task[GetMappingResponseV2] = { + private def getMappingV2(mappingIri: IRI): Task[GetMappingResponseV2] = { val mappingFuture: Task[GetMappingResponseV2] = mappingCache.get(mappingIri) match { case Some(mapping: MappingXMLtoStandoff) => for { - - entities <- getStandoffEntitiesFromMappingV2(mapping, requestingUser) - + entities <- getStandoffEntitiesFromMappingV2(mapping) } yield GetMappingResponseV2( mappingIri = mappingIri, mapping = mapping, @@ -690,10 +683,8 @@ final case class StandoffResponderV2( case None => for { - mapping <- getMappingFromTriplestore(mappingIri = mappingIri) - - entities <- getStandoffEntitiesFromMappingV2(mapping, requestingUser) - + mapping <- getMappingFromTriplestore(mappingIri = mappingIri) + entities <- getStandoffEntitiesFromMappingV2(mapping) } yield GetMappingResponseV2( mappingIri = mappingIri, mapping = mapping, @@ -823,12 +814,10 @@ final case class StandoffResponderV2( * Gets the required standoff entities (classes and properties) from the mapping and requests information about these entities from the ontology responder. * * @param mappingXMLtoStandoff the mapping to be used. - * @param requestingUser the client that made the request. * @return a [[StandoffEntityInfoGetResponseV2]] holding information about standoff classes and properties. */ private def getStandoffEntitiesFromMappingV2( mappingXMLtoStandoff: MappingXMLtoStandoff, - requestingUser: User, ): Task[StandoffEntityInfoGetResponseV2] = { implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance @@ -863,7 +852,6 @@ final case class StandoffResponderV2( .ask[StandoffEntityInfoGetResponseV2]( StandoffEntityInfoGetRequestV2( standoffClassIris = standoffTagIrisFromMapping.map(_.toSmartIri), - requestingUser = requestingUser, ), ) @@ -891,7 +879,6 @@ final case class StandoffResponderV2( .ask[StandoffEntityInfoGetResponseV2]( StandoffEntityInfoGetRequestV2( standoffPropertyIris = standoffPropertyIrisFromOntologyResponder, - requestingUser = requestingUser, ), ) diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala index 1890cb920d..e2895a263b 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ValuesResponderV2.scala @@ -47,30 +47,7 @@ import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Select import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Update -/** - * Handles requests to read and write Knora values. - */ -trait ValuesResponderV2 { - def createValueV2( - createValue: CreateValueV2, - requestingUser: User, - apiRequestID: UUID, - ): Task[CreateValueResponseV2] - - def updateValueV2( - updateValue: UpdateValueV2, - requestingUser: User, - apiRequestId: UUID, - ): Task[UpdateValueResponseV2] - - def deleteValueV2( - deleteValue: DeleteValueV2, - requestingUser: User, - apiRequestId: UUID, - ): Task[SuccessResponseV2] -} - -final case class ValuesResponderV2Live( +final case class ValuesResponderV2( appConfig: AppConfig, iriService: IriService, messageRelay: MessageRelay, @@ -79,8 +56,7 @@ final case class ValuesResponderV2Live( searchResponderV2: SearchResponderV2, triplestoreService: TriplestoreService, permissionsResponder: PermissionsResponder, -)(implicit val stringFormatter: StringFormatter) - extends ValuesResponderV2 { +)(implicit val stringFormatter: StringFormatter) { /** * Creates a new value in an existing resource. @@ -90,7 +66,7 @@ final case class ValuesResponderV2Live( * @param apiRequestID the API request ID. * @return a [[CreateValueResponseV2]]. */ - override def createValueV2( + def createValueV2( valueToCreate: CreateValueV2, requestingUser: User, apiRequestID: UUID, @@ -452,7 +428,7 @@ final case class ValuesResponderV2Live( for { // Make a new value UUID. - newValueUUID <- ValuesResponderV2Live.makeNewValueUUID(maybeValueIri, maybeValueUUID) + newValueUUID <- ValuesResponderV2.makeNewValueUUID(maybeValueIri, maybeValueUUID) // Make an IRI for the new value. newValueIri <- @@ -539,7 +515,7 @@ final case class ValuesResponderV2Live( // Make a new value UUID. for { - newValueUUID <- ValuesResponderV2Live.makeNewValueUUID(maybeValueIri, maybeValueUUID) + newValueUUID <- ValuesResponderV2.makeNewValueUUID(maybeValueIri, maybeValueUUID) sparqlTemplateLinkUpdate <- incrementLinkValue( sourceResourceInfo = resourceInfo, @@ -584,7 +560,7 @@ final case class ValuesResponderV2Live( * @param apiRequestId the ID of the API request. * @return a [[UpdateValueResponseV2]]. */ - override def updateValueV2( + def updateValueV2( updateValue: UpdateValueV2, requestingUser: User, apiRequestId: UUID, @@ -1240,7 +1216,7 @@ final case class ValuesResponderV2Live( * @param requestingUser the user making the request. * @param apiRequestId the API request ID. */ - override def deleteValueV2( + def deleteValueV2( deleteValue: DeleteValueV2, requestingUser: User, apiRequestId: UUID, @@ -2138,8 +2114,8 @@ final case class ValuesResponderV2Live( iriService.makeUnusedIri(stringFormatter.makeRandomValueIri(resourceIri)) } -object ValuesResponderV2Live { - val layer = ZLayer.derive[ValuesResponderV2Live] +object ValuesResponderV2 { + val layer = ZLayer.derive[ValuesResponderV2] /** * Make a new value UUID considering optional custom value UUID and custom value IRI. diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala index 6a625f4f45..66f509f084 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala @@ -430,7 +430,7 @@ final case class CreateResourceV2Handler( ZIO.foreach(valuesWithIndex) { case (propertyIri, valueToCreate, valueHasOrder) => for { newValueUUID <- - ValuesResponderV2Live.makeNewValueUUID(valueToCreate.customValueIri, valueToCreate.customValueUUID) + ValuesResponderV2.makeNewValueUUID(valueToCreate.customValueIri, valueToCreate.customValueUUID) newValueIri <- iriService.checkOrCreateEntityIri( valueToCreate.customValueIri, @@ -484,7 +484,7 @@ final case class CreateResourceV2Handler( ZIO.succeed(IntervalValueInfo(valueHasIntervalStart, valueHasIntervalEnd)) case TimeValueContentV2(_, valueHasTimeStamp, _) => ZIO.succeed(TimeValueInfo(valueHasTimeStamp)) - case HierarchicalListValueContentV2(_, valueHasListNode, listNodeLabel, _) => + case HierarchicalListValueContentV2(_, valueHasListNode, _, _) => ZIO.succeed(HierarchicalListValueInfo(InternalIri(valueHasListNode))) case ColorValueContentV2(_, valueHasColor, _) => ZIO.succeed(ColorValueInfo(valueHasColor)) diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala index 8eadfbeb4e..7a67711028 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala @@ -94,6 +94,6 @@ object RouteUtilZ { def getStringValueFromQuery(ctx: RequestContext, key: String): Option[String] = ctx.request.uri.query().get(key) - def toSparqlEncodedString(s: String, errorMsg: String): ZIO[StringFormatter, BadRequestException, String] = + def toSparqlEncodedString(s: String, errorMsg: String): IO[BadRequestException, String] = ZIO.fromOption(Iri.toSparqlEncodedString(s)).orElseFail(BadRequestException(errorMsg)) } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/ValuesRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/ValuesRouteV2.scala index 7c0d6579bf..46ddd2e8c7 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/ValuesRouteV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/v2/ValuesRouteV2.scala @@ -84,7 +84,7 @@ final case class ValuesRouteV2()( requestingUser <- ZIO.serviceWithZIO[Authenticator](_.getUserADM(ctx)) apiRequestId <- Random.nextUUID ingestState = AssetIngestState.headerAssetIngestState(ctx.request.headers) - valueToCreate <- CreateValueV2.fromJsonLd(ingestState, jsonLdString, requestingUser) + valueToCreate <- CreateValueV2.fromJsonLd(ingestState, jsonLdString) response <- ZIO.serviceWithZIO[ValuesResponderV2](_.createValueV2(valueToCreate, requestingUser, apiRequestId)) } yield response, diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraApiCreateValueModel.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraApiCreateValueModel.scala new file mode 100644 index 0000000000..cde30bb677 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraApiCreateValueModel.scala @@ -0,0 +1,173 @@ +/* + * 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.vocabulary.RDF +import zio.* + +import java.time.Instant +import java.util.UUID +import scala.jdk.CollectionConverters.* +import scala.language.implicitConversions + +import dsp.valueobjects.UuidUtil +import org.knora.webapi.core.MessageRelay +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.* +import org.knora.webapi.messages.OntologyConstants.KnoraApiV2Complex.ValueHasUUID +import org.knora.webapi.messages.OntologyConstants.Xsd +import org.knora.webapi.messages.SmartIri +import org.knora.webapi.messages.ValuesValidator +import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.messages.v2.responder.valuemessages.ValueContentV2.FileInfo +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +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 +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.resourceinfo.domain.IriConverter + +final case class KnoraApiCreateValueModel( + resourceIri: ResourceIri, + resourceClassIri: KResourceClassIri, + valuePropertyIri: PropertyIri, + valueType: SmartIri, + valueIri: Option[ValueIri], + valueUuid: Option[UUID], + valueCreationDate: Option[Instant], + valuePermissions: Option[String], + valueFileValueFilename: Option[String], + private val valueResource: Resource, + private val converter: IriConverter, +) { + lazy val shortcode: Shortcode = resourceIri.shortcode + + def getValueContent(fileInfo: Option[FileInfo] = None): ZIO[MessageRelay, String, ValueContentV2] = + def withFileInfo[T](f: FileInfo => Either[String, T]): IO[String, T] = + fileInfo match + case None => ZIO.fail("FileInfo is missing") + case Some(info) => ZIO.fromEither(f(info)) + valueType.toString match + case AudioFileValue => withFileInfo(AudioFileValueContentV2.from(valueResource, _)) + case ArchiveFileValue => withFileInfo(ArchiveFileValueContentV2.from(valueResource, _)) + case BooleanValue => ZIO.fromEither(BooleanValueContentV2.from(valueResource)) + case ColorValue => ZIO.fromEither(ColorValueContentV2.from(valueResource)) + case DateValue => ZIO.fromEither(DateValueContentV2.from(valueResource)) + case DecimalValue => ZIO.fromEither(DecimalValueContentV2.from(valueResource)) + case DocumentFileValue => withFileInfo(DocumentFileValueContentV2.from(valueResource, _)) + case GeomValue => ZIO.fromEither(GeomValueContentV2.from(valueResource)) + case GeonameValue => ZIO.fromEither(GeonameValueContentV2.from(valueResource)) + case IntValue => ZIO.fromEither(IntegerValueContentV2.from(valueResource)) + case IntervalValue => ZIO.fromEither(IntervalValueContentV2.from(valueResource)) + case ListValue => HierarchicalListValueContentV2.from(valueResource, converter) + case LinkValue => LinkValueContentV2.from(valueResource, converter) + case MovingImageFileValue => withFileInfo(MovingImageFileValueContentV2.from(valueResource, _)) + case StillImageExternalFileValue => ZIO.fromEither(StillImageExternalFileValueContentV2.from(valueResource)) + case StillImageFileValue => withFileInfo(StillImageFileValueContentV2.from(valueResource, _)) + case TextValue => TextValueContentV2.from(valueResource) + case TextFileValue => withFileInfo(TextFileValueContentV2.from(valueResource, _)) + case TimeValue => ZIO.fromEither(TimeValueContentV2.from(valueResource)) + case UriValue => ZIO.fromEither(UriValueContentV2.from(valueResource)) + case _ => ZIO.fail(s"Unsupported value type: $valueType") +} + +object KnoraApiCreateValueModel { self => + + // available for ease of use in tests + def fromJsonLd(str: String): ZIO[Scope & IriConverter, String, KnoraApiCreateValueModel] = + ZIO.service[IriConverter].flatMap(self.fromJsonLd(str, _)) + + def fromJsonLd(str: String, converter: IriConverter): ZIO[Scope & IriConverter, String, KnoraApiCreateValueModel] = + for { + model <- ModelOps.fromJsonLd(str) + resourceAndIri <- resourceAndIri(model, converter) + (resource, resourceIri) = resourceAndIri + resourceClassIri <- resourceClassIri(resource, converter) + valueStatement <- valueStatement(resource) + propertyIri <- valuePropertyIri(converter, valueStatement) + valueType <- valueType(valueStatement, converter) + valueResource = valueStatement.getObject.asResource() + valueIri <- valueIri(valueResource, converter) + valueUuid <- ZIO.fromEither(valueHasUuid(valueResource)) + valueCreationDate <- ZIO.fromEither(valueCreationDate(valueResource)) + valuePermissions <- ZIO.fromEither(valuePermissions(valueResource)) + valueFileValueFilename <- ZIO.fromEither(valueFileValueFilename(valueResource)) + } yield KnoraApiCreateValueModel( + resourceIri, + resourceClassIri, + propertyIri, + valueType, + valueIri, + valueUuid, + valueCreationDate, + valuePermissions, + valueFileValueFilename, + valueResource, + converter, + ) + + private def resourceAndIri(model: Model, convert: IriConverter): IO[String, (Resource, ResourceIri)] = + ZIO.fromEither(model.singleRootResource).flatMap { (r: Resource) => + convert + .asSmartIri(r.uri.getOrElse("")) + .mapError(_.getMessage) + .flatMap(iri => ZIO.fromEither(KResourceIri.from(iri))) + .map((r, _)) + } + + private def valueStatement(rootResource: Resource): IO[String, Statement] = ZIO + .succeed(rootResource.listProperties().asScala.filter(_.getPredicate != RDF.`type`).toList) + .filterOrFail(_.nonEmpty)("No value property found in root resource") + .filterOrFail(_.size == 1)("Multiple value properties found in root resource") + .map(_.head) + + private def valuePropertyIri(converter: IriConverter, valueStatement: Statement) = + converter + .asSmartIri(valueStatement.predicateUri) + .mapError(_.getMessage) + .flatMap(iri => ZIO.fromEither(PropertyIri.from(iri))) + + private def valueType(stmt: Statement, converter: IriConverter) = ZIO + .fromEither(stmt.objectAsResource().flatMap(_.rdfsType.toRight("No rdf:type found for value."))) + .orElseFail(s"No value type found for value.") + .flatMap(converter.asSmartIri(_).mapError(_.getMessage)) + + private def valueIri(valueResource: Resource, converter: IriConverter): IO[String, Option[ValueIri]] = ZIO + .fromOption(valueResource.uri) + .flatMap(converter.asSmartIri(_).mapError(_.getMessage).asSomeError) + .flatMap(iri => ZIO.fromEither(ValueIri.from(iri)).asSomeError) + .unsome + + private def valueHasUuid(valueResource: Resource): Either[String, Option[UUID]] = + valueResource.objectStringOption(ValueHasUUID).flatMap { + case Some(str) => + UuidUtil.base64Decode(str).map(Some(_)).toEither.left.map(e => s"Invalid UUID '$str': ${e.getMessage}") + case None => Right(None) + } + + private def valueCreationDate(valueResource: Resource): Either[String, Option[Instant]] = + valueResource.objectDataTypeOption(ValueCreationDate, Xsd.DateTimeStamp).flatMap { + case Some(str) => ValuesValidator.parseXsdDateTimeStamp(str).map(Some(_)) + case None => Right(None) + } + + private def valuePermissions(valueResource: Resource): Either[String, Option[String]] = + valueResource.objectStringOption(HasPermissions) + + private def valueFileValueFilename(valueResource: Resource): Either[String, Option[String]] = + valueResource.objectStringOption(FileValueHasFilename) + + private def resourceClassIri(rootResource: Resource, convert: IriConverter): IO[String, KResourceClassIri] = ZIO + .fromOption(rootResource.rdfsType) + .orElseFail("No root resource class IRI found") + .flatMap(convert.asSmartIri(_).mapError(_.getMessage)) + .flatMap(iri => ZIO.fromEither(KResourceClassIri.from(iri))) +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala new file mode 100644 index 0000000000..a2b0d2bec2 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/KnoraIris.scala @@ -0,0 +1,88 @@ +/* + * 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 eu.timepit.refined.types.string.NonEmptyString + +import org.knora.webapi.ApiV2Complex +import org.knora.webapi.ApiV2Simple +import org.knora.webapi.OntologySchema +import org.knora.webapi.messages.SmartIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.resourceinfo.domain.InternalIri + +object KnoraIris { + + opaque type ResourceId = NonEmptyString + opaque type ValueId = NonEmptyString + + trait KnoraIri { self => + def smartIri: SmartIri + override def toString: String = self.smartIri.toString + def toInternal: InternalIri = self.smartIri.toInternalIri + def toApiV2Complex: SmartIri = self.toOntologySchema(ApiV2Complex) + def toApiV2Simple: SmartIri = self.toOntologySchema(ApiV2Simple) + def toOntologySchema(s: OntologySchema): SmartIri = self.smartIri.toOntologySchema(s) + } + + final case class PropertyIri private (smartIri: SmartIri) extends KnoraIri + + object PropertyIri { + def unsafeFrom(iri: SmartIri): PropertyIri = from(iri).fold(e => throw IllegalArgumentException(e), identity) + def from(iri: SmartIri): Either[String, PropertyIri] = + if (!iri.isKnoraEntityIri && iri.isApiV2ComplexSchema) { + Left(s"<$iri> is not a Knora API v2 complex property IRI") + } else { + Right(PropertyIri(iri)) + } + } + + final case class ValueIri private ( + smartIri: SmartIri, + shortcode: Shortcode, + resourceId: ResourceId, + valueId: ValueId, + ) extends KnoraIri + + object ValueIri { + def from(iri: SmartIri): Either[String, ValueIri] = + if (!iri.isKnoraValueIri) { + Left(s"<$iri> is not a Knora value IRI") + } else { + // the following three calls are safe because we checked that the + // shortcode, resourceId and valueId are present in isKnoraValueIri + val shortcode = iri.getProjectShortcode.getOrElse(throw Exception()) + val resourceId = NonEmptyString.unsafeFrom(iri.getResourceID.getOrElse(throw Exception())) + val valueId = NonEmptyString.unsafeFrom(iri.getValueID.getOrElse(throw Exception())) + Right(ValueIri(iri, shortcode, resourceId, valueId)) + } + } + + final case class ResourceIri private (smartIri: SmartIri, shortcode: Shortcode, resourceId: ResourceId) + extends KnoraIri + object ResourceIri { + def from(iri: SmartIri): Either[String, ResourceIri] = + if (!iri.isKnoraResourceIri) { + Left(s"<$iri> is not a Knora resource IRI") + } else { + // the following two calls are safe because we checked that the + // shortcode and resourceId are present in isKnoraResourceIri + val shortcode = iri.getProjectShortcode.getOrElse(throw Exception()) + val resourceId = NonEmptyString.unsafeFrom(iri.getResourceID.getOrElse(throw Exception())) + Right(ResourceIri(iri, shortcode, resourceId)) + } + } + + final case class ResourceClassIri private (smartIri: SmartIri) extends KnoraIri + + object ResourceClassIri { + def from(iri: SmartIri): Either[String, ResourceClassIri] = + if (!iri.isKnoraEntityIri && iri.isApiV2ComplexSchema) { + Left(s"<$iri> is not a Knora resource class IRI") + } else { + Right(ResourceClassIri(iri)) + } + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/ModelOps.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/ModelOps.scala deleted file mode 100644 index 394770f659..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/ModelOps.scala +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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 -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/JenaConversions.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/JenaConversions.scala new file mode 100644 index 0000000000..19b035d554 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/JenaConversions.scala @@ -0,0 +1,18 @@ +/* + * 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.jena + +import org.apache.jena.rdf.model.* + +object JenaConversions { + + given Conversion[String, Property] with + override def apply(x: String): Property = ResourceFactory.createProperty(x) + + given Conversion[String, Resource] with + override def apply(x: String): Resource = ResourceFactory.createResource(x) + +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala new file mode 100644 index 0000000000..d494165d3d --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ModelOps.scala @@ -0,0 +1,56 @@ +/* + * 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.jena + +import org.apache.jena.rdf.model.* +import org.apache.jena.riot.Lang +import org.apache.jena.riot.RDFDataMgr +import zio.Scope +import zio.UIO +import zio.ZIO + +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets +import scala.jdk.CollectionConverters.* + +object ModelOps { self => + + extension (model: Model) { + def printTurtle: UIO[Unit] = + ZIO.attempt(RDFDataMgr.write(java.lang.System.out, model, Lang.TURTLE)).logError.ignore + + def resourceOption(uri: String): Option[Resource] = Option(model.getResource(uri)) + def resource(uri: String): Either[String, Resource] = + model.resourceOption(uri).toRight(s"Resource not found '$uri'") + + def statementOption(s: Resource, p: Property): Option[Statement] = Option(model.getProperty(s, p)) + def statement(s: Resource, p: Property): Either[String, Statement] = + statementOption(s, p).toRight(s"Statement not found '${s.getURI} ${p.getURI} ?o .'") + + def singleRootResource: Either[String, Resource] = + val objSeen = model.listObjects().asScala.collect { case r: Resource => Option(r.getURI) }.toSet.flatten + val subSeen = model.listSubjects().asScala.collect { case r: Resource => Option(r.getURI) }.toSet.flatten + (subSeen -- objSeen) match { + case iris if iris.size == 1 => model.resource(iris.head) + case iris if iris.isEmpty => Left("No root resource found in model") + case iris => Left(s"Multiple root resources found in model: ${iris.mkString(", ")}") + } + } + + def fromJsonLd(str: String): ZIO[Scope, String, Model] = from(str, Lang.JSONLD) + def fromTurtle(str: String): ZIO[Scope, String, Model] = from(str, Lang.TURTLE) + + private val createModel = + ZIO.acquireRelease(ZIO.succeed(ModelFactory.createDefaultModel()))(m => ZIO.succeed(m.close())) + + def from(str: String, lang: Lang): ZIO[Scope, String, Model] = + for { + m <- createModel + _ <- ZIO + .attempt(RDFDataMgr.read(m, ByteArrayInputStream(str.getBytes(StandardCharsets.UTF_8)), lang)) + .mapError(_.getMessage) + } yield m +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ResourceOps.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ResourceOps.scala new file mode 100644 index 0000000000..65c5e4ad3e --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/ResourceOps.scala @@ -0,0 +1,55 @@ +/* + * 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.jena + +import org.apache.jena.rdf.model.Property +import org.apache.jena.rdf.model.Resource +import org.apache.jena.rdf.model.Statement +import org.apache.jena.vocabulary.RDF + +import java.time.Instant + +import org.knora.webapi.slice.common.jena.StatementOps.* + +object ResourceOps { + + extension (res: Resource) { + def statementOption(p: Property): Option[Statement] = Option(res.getProperty(p)) + def statement(p: Property): Either[String, Statement] = + statementOption(p).toRight(s"Required property not found ${p.getURI}") + + private inline def fromStatement[A](p: Property, f: Statement => Either[String, A]): Either[String, Option[A]] = + statementOption(p) match + case Some(stmt) => f.apply(stmt).map(Some(_)) + case None => Right(None) + + def objectBigDecimal(p: Property): Either[String, BigDecimal] = statement(p).flatMap(_.objectAsBigDecimal) + def objectBigDecimalOption(p: Property): Either[String, Option[BigDecimal]] = fromStatement(p, _.objectAsBigDecimal) + + def objectBoolean(p: Property): Either[String, Boolean] = statement(p).flatMap(_.objectAsBoolean) + def objectBooleanOption(p: Property): Either[String, Option[Boolean]] = fromStatement(p, _.objectAsBoolean) + + def objectInstant(p: Property): Either[String, Instant] = statement(p).flatMap(_.objectAsInstant) + def objectInstantOption(p: Property): Either[String, Option[Instant]] = fromStatement(p, _.objectAsInstant) + + def objectInt(p: Property): Either[String, Int] = statement(p).flatMap(_.objectAsInt) + def objectIntOption(p: Property): Either[String, Option[Int]] = fromStatement(p, _.objectAsInt) + + def objectString(p: Property): Either[String, String] = statement(p).flatMap(_.objectAsString) + def objectStringOption(p: Property): Either[String, Option[String]] = fromStatement(p, _.objectAsString) + + def objectUri(p: Property): Either[String, String] = statement(p).flatMap(stmt => stmt.objectAsUri) + def objectUriOption(p: Property): Either[String, Option[String]] = fromStatement(p, _.objectAsUri) + + def objectDataType(p: Property, dt: String): Either[String, String] = + statement(p).flatMap(stmt => stmt.objectAsDataType(dt)) + def objectDataTypeOption(p: Property, dt: String): Either[String, Option[String]] = + fromStatement(p, _.objectAsDataType(dt)) + + def rdfsType: Option[String] = Option(res.getPropertyResourceValue(RDF.`type`)).flatMap(_.uri) + def uri: Option[String] = Option(res.getURI) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/jena/StatementOps.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/StatementOps.scala new file mode 100644 index 0000000000..da1ed5e9b0 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/jena/StatementOps.scala @@ -0,0 +1,59 @@ +/* + * 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.jena + +import org.apache.jena.rdf.model.Literal +import org.apache.jena.rdf.model.Resource +import org.apache.jena.rdf.model.Statement + +import java.time.Instant +import scala.util.Try + +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.slice.common.jena.ResourceOps.* + +object StatementOps { + extension (stmt: Statement) { + def subjectUri(): Option[String] = Option(stmt.getSubject.getURI) + def objectAsResource(): Either[String, Resource] = + Try(stmt.getObject.asResource()).toEither.left.map(e => + s"Object is not a resource: ${stmt.getPredicate} ${e.getMessage}", + ) + + def objectAsBigDecimal: Either[String, BigDecimal] = + objectAsDataType(OntologyConstants.Xsd.Decimal).flatMap(str => + Try(BigDecimal(str)).toEither.left.map(_ => s"Invalid decimal value for property ${stmt.getPredicate}"), + ) + + def objectAsBoolean: Either[String, Boolean] = + Try(stmt.getBoolean).toEither.left.map(_ => s"Invalid boolean value for property ${stmt.getPredicate}") + + def objectAsInstant: Either[String, Instant] = + objectAsDataType(OntologyConstants.Xsd.DateTimeStamp).flatMap(str => + Try(Instant.parse(str)).toEither.left.map(_ => + s"Invalid date time timestamp value for property ${stmt.getPredicate}", + ), + ) + + def objectAsInt: Either[String, Int] = + Try(stmt.getInt).toEither.left.map(_ => s"Invalid integer value for property ${stmt.getPredicate}") + + def objectAsString: Either[String, String] = + Try(stmt.getString).toEither.left.map(_ => s"Invalid string value for property ${stmt.getPredicate}") + + def objectAsUri: Either[String, String] = + objectAsResource().flatMap(_.uri.toRight(s"Invalid URI value for property ${stmt.getPredicate}")) + + def objectAsDataType(dataTypeUri: String): Either[String, String] = + stmt.getObject match + case l: Literal => + l.getDatatypeURI match + case `dataTypeUri` => Right(l.getLexicalForm) + case _ => Left(s"Invalid datatype for property ${stmt.getPredicate}, ${dataTypeUri} expected") + + def predicateUri: String = stmt.getPredicate.getURI + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala index 5fded5614a..5879ae6f1d 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/resourceinfo/domain/IriConverter.scala @@ -27,6 +27,7 @@ final case class IriConverter(sf: StringFormatter) { getOntologySmartIriFromClassIri(iri).mapAttempt(_.toInternalIri) def getOntologySmartIriFromClassIri(iri: InternalIri): Task[SmartIri] = asInternalSmartIri(iri.value).mapAttempt(_.getOntologyFromEntity) + def isKnoraDataIri(iri: String): Task[Boolean] = asSmartIri(iri).map(_.isKnoraDataIri) } object IriConverter { diff --git a/webapi/src/test/scala/org/knora/webapi/messages/v2/responder/valuemessages/CreateValueV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/messages/v2/responder/valuemessages/CreateValueV2Spec.scala index 5408b81a40..de81aabbcd 100644 --- a/webapi/src/test/scala/org/knora/webapi/messages/v2/responder/valuemessages/CreateValueV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/messages/v2/responder/valuemessages/CreateValueV2Spec.scala @@ -14,19 +14,11 @@ import zio.test.assertTrue import zio.test.check import org.knora.webapi.ApiV2Complex -import org.knora.webapi.IRI import org.knora.webapi.core.MessageRelay import org.knora.webapi.core.MessageRelayLive import org.knora.webapi.messages.StringFormatter -import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionADM -import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsDataADM import org.knora.webapi.messages.v2.responder.valuemessages.TextValueType.UnformattedText import org.knora.webapi.routing.v2.AssetIngestState.AssetIngested -import org.knora.webapi.slice.admin.api.model.Project -import org.knora.webapi.slice.admin.domain.model.Group -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.admin.domain.service.KnoraGroupRepo -import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo import org.knora.webapi.slice.common.JsonLdTestUtil import org.knora.webapi.slice.common.JsonLdTestUtil.JsonLdTransformations import org.knora.webapi.slice.resourceinfo.domain.IriConverter @@ -37,42 +29,23 @@ object CreateValueV2Spec extends ZIOSpecDefault { private val unformattedTextValueWithLanguage = """ - |{ - | "@id": "http://rdfh.ch/0001/a-thing", - | "@type": "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing", - | "http://0.0.0.0:3333/ontology/0001/anything/v2#hasText":{ - | "@type":"http://api.knora.org/ontology/knora-api/v2#TextValue", - | "http://api.knora.org/ontology/knora-api/v2#valueAsString":"This is English", - | "http://api.knora.org/ontology/knora-api/v2#textValueHasLanguage":"en" - | } - |}""".stripMargin - - private val rootUser = - User( - id = "http://rdfh.ch/users/root", - username = "root", - email = "root@example.com", - givenName = "System", - familyName = "Administrator", - status = true, - lang = "de", - password = Option("$2a$12$7XEBehimXN1rbhmVgQsyve08.vtDmKK7VMin4AdgCEtE4DWgfQbTK"), - groups = Seq.empty[Group], - projects = Seq.empty[Project], - permissions = PermissionsDataADM( - groupsPerProject = Map( - KnoraProjectRepo.builtIn.SystemProject.id.value -> List(KnoraGroupRepo.builtIn.SystemAdmin.id.value), - ), - administrativePermissionsPerProject = Map.empty[IRI, Set[PermissionADM]], - ), - ) +{ + "@id": "http://rdfh.ch/0001/a-thing", + "@type": "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing", + "http://0.0.0.0:3333/ontology/0001/anything/v2#hasText":{ + "@type":"http://api.knora.org/ontology/knora-api/v2#TextValue", + "http://api.knora.org/ontology/knora-api/v2#valueAsString":"This is English", + "http://api.knora.org/ontology/knora-api/v2#textValueHasLanguage":"en" + } +}""".stripMargin override def spec: Spec[Any, Throwable] = suite("CreateValueV2Spec")(test("UnformattedText TextValue fromJsonLd should contain the language") { - check(Gen.fromIterable(Seq(JsonLdTransformations.noOp))) { f => + val transformations = JsonLdTransformations.all + check(Gen.fromIterable(transformations)) { f => for { sf <- ZIO.service[StringFormatter] - value <- CreateValueV2.fromJsonLd(AssetIngested, f(unformattedTextValueWithLanguage), rootUser) + value <- CreateValueV2.fromJsonLd(AssetIngested, f(unformattedTextValueWithLanguage)) } yield assertTrue( value == CreateValueV2( resourceIri = "http://rdfh.ch/0001/a-thing", diff --git a/webapi/src/test/scala/org/knora/webapi/slice/common/KnoraApiValueModelSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/common/KnoraApiValueModelSpec.scala index 7eff61e49d..0a3813fc1d 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/common/KnoraApiValueModelSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/common/KnoraApiValueModelSpec.scala @@ -5,16 +5,69 @@ package org.knora.webapi.slice.common -import zio.Scope +import zio.* import zio.json.DecoderOps import zio.json.EncoderOps import zio.json.ast.Json import zio.test.* +import java.time.Instant + +import org.knora.webapi.ApiV2Complex +import org.knora.webapi.core.MessageRelayLive import org.knora.webapi.messages.StringFormatter +import org.knora.webapi.messages.util.CalendarNameGregorian +import org.knora.webapi.messages.util.DatePrecisionDay +import org.knora.webapi.messages.v2.responder.valuemessages.ArchiveFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.AudioFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.BooleanValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.ColorValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.DateValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.DecimalValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.DocumentFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.FileValueV2 +import org.knora.webapi.messages.v2.responder.valuemessages.GeomValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.GeonameValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.HierarchicalListValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.IntegerValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.IntervalValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.LinkValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.MovingImageFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.StillImageExternalFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.StillImageFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.TextFileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.TimeValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.UriValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.ValueContentV2.FileInfo +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.common.KnoraIris.* import org.knora.webapi.slice.resourceinfo.domain.IriConverter +import org.knora.webapi.slice.resources.IiifImageRequestUrl +import org.knora.webapi.store.iiif.api.FileMetadataSipiResponse object KnoraApiValueModelSpec extends ZIOSpecDefault { + private val sf = StringFormatter.getInitializedTestInstance + + private val givenFileInfo = FileInfo( + "internalFilename", + FileMetadataSipiResponse( + Some("originalFilename"), + Some("originalMimeType"), + "internalMimeType", + Some(640), + Some(480), + Some(666), + None, + None, + ), + ) + + private val expectedFileValue = FileValueV2( + "internalFilename", + "internalMimeType", + Some("originalFilename"), + Some("originalMimeType"), + ) private val createIntegerValue = """ { @@ -58,17 +111,520 @@ object KnoraApiValueModelSpec extends ZIOSpecDefault { test("getResourceIri should get the id") { check(Gen.fromIterable(Seq(createIntegerValue, createLinkValue).map(_.toJsonPretty))) { json => for { - model <- KnoraApiValueModel.fromJsonLd(json) - } yield assertTrue(model.rootResourceIri.toString == "http://rdfh.ch/0001/a-thing") + model <- KnoraApiCreateValueModel.fromJsonLd(json) + } yield assertTrue(model.resourceIri.toString == "http://rdfh.ch/0001/a-thing") } }, test("rootResourceClassIri should get the rdfs:type") { check(Gen.fromIterable(Seq(createIntegerValue, createLinkValue).map(_.toJsonPretty))) { json => for { - model <- KnoraApiValueModel.fromJsonLd(json) - resourceClassIri <- model.rootResourceClassIri - } yield assertTrue(resourceClassIri.toString == "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing") + model <- KnoraApiCreateValueModel.fromJsonLd(json) + } yield assertTrue(model.resourceClassIri.toString == "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing") } }, - ).provideSome[Scope](IriConverter.layer, StringFormatter.test) + test("valueNode properties should be present") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd(createIntegerValue.toJsonPretty) + propertyIri = model.valuePropertyIri + valueType = model.valueType + } yield assertTrue( + propertyIri == PropertyIri.unsafeFrom( + sf.toSmartIri("http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger"), + ), + valueType == sf.toSmartIri("http://api.knora.org/ontology/knora-api/v2#IntValue"), + model.shortcode == Shortcode.unsafeFrom("0001"), + ) + }, + test("should parse integer value") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someInt": { + | "@type": "ka:IntValue", + | "ka:intValueAsInt": { + | "@type": "xsd:integer", + | "@value": 4 + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue(content == IntegerValueContentV2(ApiV2Complex, 4, None)) + }, + test("should parse DecimalValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someDec": { + | "@type": "ka:DecimalValue", + | "ka:decimalValueAsDecimal": { + | "@type": "xsd:decimal", + | "@value": "4" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue(content == DecimalValueContentV2(ApiV2Complex, BigDecimal(4), None)) + }, + test("should parse BooleanValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someBool": { + | "@type": "ka:BooleanValue", + | "ka:booleanValueAsBoolean": { + | "@type": "xsd:boolean", + | "@value": "true" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue(content == BooleanValueContentV2(ApiV2Complex, true, None)) + }, + test("should parse GeomValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someGeom": { + | "@type": "ka:GeomValue", + | "ka:geometryValueAsGeometry": "{}" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue(content == GeomValueContentV2(ApiV2Complex, "{}", None)) + }, + test("should parse IntervalValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someInterval": { + | "@type": "ka:IntervalValue", + | "ka:intervalValueHasStart": { + | "@type": "xsd:decimal", + | "@value": 4 + | }, + | "ka:intervalValueHasEnd": { + | "@type": "xsd:decimal", + | "@value": 2 + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue(content == IntervalValueContentV2(ApiV2Complex, BigDecimal(4), BigDecimal(2), None)) + }, + test("should parse TimeValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd("""{ + | "@id": "http://rdfh.ch/0001/a-thing", + | "@type": "ex:Thing", + | "ex:someTimeValue": { + | "@type": "ka:TimeValue", + | "ka:timeValueAsTimeStamp": { + | "@type": "xsd:dateTimeStamp", + | "@value": "2020-06-04T11:36:54.502951Z" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin) + content <- model.getValueContent() + } yield assertTrue( + content == TimeValueContentV2(ApiV2Complex, Instant.parse("2020-06-04T11:36:54.502951Z"), None), + ) + }, + test("should parse LinkValueContentV2") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:LinkValue", + | "ka:linkValueHasTargetIri" : { + | "@id" : "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue( + content == LinkValueContentV2( + ApiV2Complex, + referredResourceIri = "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA", + comment = None, + ), + ) + }, + test("should parse UriValueContentV2") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:UriValue", + | "ka:uriValueAsUri" : { + | "@type" : "xsd:anyURI", + | "@value" : "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue(content == UriValueContentV2(ApiV2Complex, "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA", None)) + }, + test("should parse GeonameValueContentV2") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:GeonameValue", + | "ka:geonameValueAsGeonameCode" : "foo" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue(content == GeonameValueContentV2(ApiV2Complex, "foo", None)) + }, + test("should parse ColorValue") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:ColorValue", + | "ka:colorValueAsColor" : "red" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue(content == ColorValueContentV2(ApiV2Complex, "red", None)) + }, + test("should parse StillImageFileValue") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:StillImageFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == StillImageFileValueContentV2( + ApiV2Complex, + expectedFileValue, + givenFileInfo.metadata.width.getOrElse(throw new Exception("width is missing")), + givenFileInfo.metadata.height.getOrElse(throw new Exception("height is missing")), + None, + ), + ) + }, + test("should parse StillImageExternalFileValue") { + for { + model <- + KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:StillImageExternalFileValue", + | "ka:stillImageFileValueHasExternalUrl" : "http://www.example.org/prefix1/abcd1234/full/0/native.jpg" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue( + content == StillImageExternalFileValueContentV2( + ApiV2Complex, + FileValueV2( + "internalFilename", + "internalMimeType", + Some("originalFilename"), + Some("originalMimeType"), + ), + IiifImageRequestUrl.unsafeFrom("http://www.example.org/prefix1/abcd1234/full/0/native.jpg"), + None, + ), + ) + }, + test("should parse DocumentFileValue") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:DocumentFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == DocumentFileValueContentV2( + ApiV2Complex, + expectedFileValue, + givenFileInfo.metadata.numpages, + givenFileInfo.metadata.width, + givenFileInfo.metadata.height, + None, + ), + ) + }, + test("should parse TextFileValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:TextFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == TextFileValueContentV2(ApiV2Complex, expectedFileValue, None), + ) + }, + test("should parse AudioFileValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:AudioFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == AudioFileValueContentV2(ApiV2Complex, expectedFileValue, None), + ) + }, + test("should parse MovingImageFileValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:MovingImageFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == MovingImageFileValueContentV2(ApiV2Complex, expectedFileValue, None), + ) + }, + test("should parse ArchiveFileValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:ArchiveFileValue" + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == ArchiveFileValueContentV2(ApiV2Complex, expectedFileValue, None), + ) + }, + test("should parse HierarchicalListValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:ListValue", + | "ka:listValueAsListNode": { + | "@id" : "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA" + | } + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent(Some(givenFileInfo)) + } yield assertTrue( + content == HierarchicalListValueContentV2( + ApiV2Complex, + "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA", + None, + None, + ), + ) + }, + test("should parse DateValueContentV2") { + for { + model <- KnoraApiCreateValueModel.fromJsonLd( + s""" + |{ + | "@id" : "http://rdfh.ch/0001/a-thing", + | "@type" : "ex:Thing", + | "ex:hasOtherThingValue" : { + | "@id" : "http://rdfh.ch/0001/a-thing/values/mr9i2aUUJolv64V_9hYdTw", + | "@type" : "ka:DateValue", + | "ka:dateValueHasCalendar" : "GREGORIAN", + | "ka:dateValueHasEndEra" : "CE", + | "ka:dateValueHasEndYear" : 1489, + | "ka:dateValueHasEndMonth" : 12, + | "ka:dateValueHasEndDay" : 24, + | "ka:dateValueHasStartEra" : "CE", + | "ka:dateValueHasStartMonth" : 1, + | "ka:dateValueHasStartDay" : 28, + | "ka:dateValueHasStartYear" : 1488 + | }, + | "@context": { + | "ka": "http://api.knora.org/ontology/knora-api/v2#", + | "ex": "https://example.com/test#", + | "xsd": "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin, + ) + content <- model.getValueContent() + } yield assertTrue( + content == DateValueContentV2( + ApiV2Complex, + 2264568, + 2265264, + DatePrecisionDay, + DatePrecisionDay, + CalendarNameGregorian, + None, + ), + ) + }, + ).provideSome[Scope](IriConverter.layer, MessageRelayLive.layer, StringFormatter.test) + } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/common/JenaModelOpsSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/common/jena/ModelOpsSpec.scala similarity index 94% rename from webapi/src/test/scala/org/knora/webapi/slice/common/JenaModelOpsSpec.scala rename to webapi/src/test/scala/org/knora/webapi/slice/common/jena/ModelOpsSpec.scala index ae25438764..926b4c4885 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/common/JenaModelOpsSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/common/jena/ModelOpsSpec.scala @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -package org.knora.webapi.slice.common +package org.knora.webapi.slice.common.jena import zio.* import zio.test.* -object JenaModelOpsSpec extends ZIOSpecDefault { +object ModelOpsSpec extends ZIOSpecDefault { private val jsonLd = """{ "@id" : "http://rdfh.ch/0001/a-thing", @@ -40,7 +40,7 @@ object JenaModelOpsSpec extends ZIOSpecDefault { } } """.stripMargin - val spec = suite("JenaModelOps")( + val spec = suite("ModelOps")( suite("fromJsonLd")( test("should parse the json ld") { ModelOps.fromJsonLd(jsonLd).flatMap { model => diff --git a/webapi/src/test/scala/org/knora/webapi/slice/common/jena/ResourceOpsSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/common/jena/ResourceOpsSpec.scala new file mode 100644 index 0000000000..775881346b --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/common/jena/ResourceOpsSpec.scala @@ -0,0 +1,289 @@ +/* + * 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.jena + +import zio.* +import zio.test.* + +import java.time.Instant +import scala.language.implicitConversions + +import org.knora.webapi.slice.common +import org.knora.webapi.slice.common.jena.JenaConversions.given +import org.knora.webapi.slice.common.jena.ModelOps.* +import org.knora.webapi.slice.common.jena.ResourceOps.* + +object ResourceOpsSpec extends ZIOSpecDefault { + + private val integerValue = + """ + | @prefix ex: . + | @prefix xsd: . + | @prefix rdfs: . + | + | + | a ex:Thing ; + | ex:int "4"^^xsd:integer ; + | ex:str "Foo" ; + | ex:decimal "42.0"^^xsd:decimal ; + | ex:bool "true"^^xsd:boolean ; + | ex:dts "1879-03-14T00:00:00Z"^^xsd:dateTimeStamp . + |""".stripMargin + + private def resource() = + ModelOps + .fromTurtle(integerValue) + .map(_.resource("https://example.com/a-thing")) + .flatMap(ZIO.fromEither(_)) + .mapError(msg => s"Failing to parse model: $msg") + + private val objectBooleanSuite = suite("Getting Boolean values of Objects")( + suite("objectBoolean")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectBoolean("https://example.com/test#bool") + } yield assertTrue(actual == Right(true)) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectBoolean("https://example.com/test#str") + } yield assertTrue(actual == Left("Invalid boolean value for property https://example.com/test#str")) + }, + test("should fail if property is not present ") { + for { + res <- resource() + actual = res.objectBoolean("https://example.com/test#notPresent") + } yield assertTrue(actual == Left("Required property not found https://example.com/test#notPresent")) + }, + ), + suite("objectBooleanOption")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectBooleanOption("https://example.com/test#bool") + } yield assertTrue(actual == Right(Some(true))) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectBooleanOption("https://example.com/test#str") + } yield assertTrue(actual == Left("Invalid boolean value for property https://example.com/test#str")) + }, + test("should succeed if property is not present ") { + for { + res <- resource() + actual = res.objectBooleanOption("https://example.com/test#notPresent") + } yield assertTrue(actual == Right(None)) + }, + ), + ) + + private val objectBigDecimalSuite = suite("Getting BigDecimal of Objects")( + suite("objectBigDecimal")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectBigDecimal("https://example.com/test#decimal") + } yield assertTrue(actual == Right(BigDecimal(42))) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectBigDecimal("https://example.com/test#str") + } yield assertTrue( + actual == Left( + "Invalid datatype for property https://example.com/test#str, http://www.w3.org/2001/XMLSchema#decimal expected", + ), + ) + }, + test("should fail if property is not present ") { + for { + res <- resource() + actual = res.objectBigDecimal("https://example.com/test#notPresent") + } yield assertTrue(actual == Left("Required property not found https://example.com/test#notPresent")) + }, + ), + suite("objectBigDecimalOption")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectBigDecimalOption("https://example.com/test#decimal") + } yield assertTrue(actual == Right(Some(BigDecimal(42)))) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectBigDecimalOption("https://example.com/test#str") + } yield assertTrue( + actual == Left( + "Invalid datatype for property https://example.com/test#str, http://www.w3.org/2001/XMLSchema#decimal expected", + ), + ) + }, + test("should succeed if property is not present ") { + for { + res <- resource() + actual = res.objectBigDecimalOption("https://example.com/test#notPresent") + } yield assertTrue(actual == Right(None)) + }, + ), + ) + + private val objectIntSuite = suite("Getting Integer values of Objects")( + suite("objectInt")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectInt("https://example.com/test#int") + } yield assertTrue(actual == Right(4)) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectInt("https://example.com/test#str") + } yield assertTrue(actual == Left("Invalid integer value for property https://example.com/test#str")) + }, + test("should fail if property is not present ") { + for { + res <- resource() + actual = res.objectInt("https://example.com/test#notPresent") + } yield assertTrue(actual == Left("Required property not found https://example.com/test#notPresent")) + }, + ), + suite("objectIntOption")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectIntOption("https://example.com/test#int") + } yield assertTrue(actual == Right(Some(4))) + }, + test("should fail if value is the wrong type") { + for { + res <- resource() + actual = res.objectIntOption("https://example.com/test#str") + } yield assertTrue(actual == Left("Invalid integer value for property https://example.com/test#str")) + }, + test("should succeed if property is not present ") { + for { + res <- resource() + actual = res.objectIntOption("https://example.com/test#notPresent") + } yield assertTrue(actual == Right(None)) + }, + ), + ) + + private val objectInstantSuite = suite("Getting Instant values of Objects")( + suite("objectInstant")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectInstant("https://example.com/test#dts") + } yield assertTrue(actual == Right(Instant.parse("1879-03-14T00:00:00Z"))) + }, + test("should fail if type is not correct") { + for { + res <- resource() + actual = res.objectInstant("https://example.com/test#int") + } yield assertTrue( + actual == Left(value = + "Invalid datatype for property https://example.com/test#int, http://www.w3.org/2001/XMLSchema#dateTimeStamp expected", + ), + ) + }, + test("should fail if property is not present ") { + for { + res <- resource() + actual = res.objectInstant("https://example.com/test#notPresent") + } yield assertTrue(actual == Left("Required property not found https://example.com/test#notPresent")) + }, + ), + suite("objectInstantOption")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectInstantOption("https://example.com/test#dts") + } yield assertTrue(actual == Right(Some(Instant.parse("1879-03-14T00:00:00Z")))) + }, + test("should fail if type is not correct") { + for { + res <- resource() + actual = res.objectInstantOption("https://example.com/test#int") + } yield assertTrue( + actual == Left( + "Invalid datatype for property https://example.com/test#int, http://www.w3.org/2001/XMLSchema#dateTimeStamp expected", + ), + ) + }, + test("should succeed if property is not present ") { + for { + res <- resource() + actual = res.objectInstantOption("https://example.com/test#notPresent") + } yield assertTrue(actual == Right(None)) + }, + ), + ) + + private val objectStringSuite = suite("Getting String values of Objects")( + suite("objectString")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectString("https://example.com/test#str") + } yield assertTrue(actual == Right("Foo")) + }, + test("should succeed if value is not a string with the string representation") { + for { + res <- resource() + actual = res.objectString("https://example.com/test#int") + } yield assertTrue(actual == Right("4")) + }, + test("should fail if property is not present ") { + for { + res <- resource() + actual = res.objectString("https://example.com/test#notPresent") + } yield assertTrue(actual == Left("Required property not found https://example.com/test#notPresent")) + }, + ), + suite("objectStringOption")( + test("should succeed if value is present") { + for { + res <- resource() + actual = res.objectStringOption("https://example.com/test#str") + } yield assertTrue(actual == Right(Some("Foo"))) + }, + test("should succeed if not value is not a string with the string representation") { + for { + res <- resource() + actual = res.objectStringOption("https://example.com/test#int") + } yield assertTrue(actual == Right(Some("4"))) + }, + test("should succeed if property is not present ") { + for { + res <- resource() + actual = res.objectStringOption("https://example.com/test#notPresent") + } yield assertTrue(actual == Right(None)) + }, + ), + ) + + private val rdfTypeTest = test("rdfsType should get the type") { + for { + res <- resource() + actual = res.rdfsType + } yield assertTrue(actual.contains("https://example.com/test#Thing")) + } + + val spec = suite("ResourceOps")( + objectBigDecimalSuite, + objectBooleanSuite, + objectIntSuite, + objectInstantSuite, + objectStringSuite, + rdfTypeTest, + ) +}