diff --git a/.sbtopts b/.sbtopts index 1ca6d0b39d..2b63e3b2d8 100644 --- a/.sbtopts +++ b/.sbtopts @@ -1 +1 @@ --J-Xmx2G +-J-Xmx4G diff --git a/docker-compose.yml b/docker-compose.yml index 59720ccb35..266fa77fc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: daschswiss/dsp-app:v11.21.0 + image: daschswiss/dsp-app:v11.22.1 ports: - "4200:4200" networks: diff --git a/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala b/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala index dd28dcc529..90a56acbcf 100644 --- a/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/E2EZSpec.scala @@ -56,6 +56,19 @@ abstract class E2EZSpec extends ZIOSpecDefault with TestStartupUtils { response <- client.url(urlFull).addHeaders(Headers(bearer)).get("").orDie } yield response + def sendPutRequestAsRoot(url: String, body: Body): URIO[env, Response] = + for { + token <- getRootToken.mapError(Exception(_)).orDie + response <- sendPutRequest(url, body, Some(token)) + } yield response + + def sendPutRequest(url: String, body: Body, token: Option[String] = None): URIO[env, Response] = + for { + client <- ZIO.service[Client] + bearer = token.map(Header.Authorization.Bearer(_)).toList + response <- client.url(url"http://localhost:3333").addHeaders(Headers(bearer)).put(url)(body).orDie + } yield response + def sendGetRequestStringOrFail(url: String, token: Option[String] = None): ZIO[env, String, String] = for { response <- sendGetRequest(url, token) diff --git a/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala b/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala index 74d5797c82..328d2f8c37 100644 --- a/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/it/v2/CopyrightAndLicensesSpec.scala @@ -10,7 +10,10 @@ import org.apache.jena.rdf.model.Property import org.apache.jena.rdf.model.Resource import org.apache.jena.vocabulary.RDF import zio.* +import zio.http.Body +import zio.http.Response import zio.test.* +import zio.test.TestAspect import java.net.URLEncoder import scala.jdk.CollectionConverters.IteratorHasAsScala @@ -25,6 +28,8 @@ import org.knora.webapi.models.filemodels.FileType import org.knora.webapi.models.filemodels.UploadFileRequest import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution import org.knora.webapi.slice.admin.domain.model.KnoraProject.License +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.common.KnoraIris.ValueIri import org.knora.webapi.slice.common.jena.JenaConversions.given import org.knora.webapi.slice.common.jena.ModelOps @@ -34,21 +39,39 @@ import org.knora.webapi.slice.resourceinfo.domain.IriConverter object CopyrightAndLicensesSpec extends E2EZSpec { - private val copyrightAttribution = CopyrightAttribution.unsafeFrom("2020, Example") - private val license = License.unsafeFrom("CC BY-SA 4.0") + private val aCopyrightAttribution = CopyrightAttribution.unsafeFrom("2020, On FileValue") + private val aLicense = License.unsafeFrom("CC BY-SA 4.0") - val e2eSpec: Spec[Scope & env, Any] = suite("Copyright Attribution and Licenses")( + private val projectCopyrightAttribution = CopyrightAttribution.unsafeFrom("2024, On Project") + private val projectLicense = License.unsafeFrom("Apache-2.0") + + private val givenProjectHasNoCopyrightAttributionAndLicenseSuite = suite( + "given the project does not have a license and does not have a copyright attribution ", + )( + test( + "when creating a resource without copyright attribution and license" + + "the creation response should not contain the license and copyright attribution", + ) { + for { + createResourceResponseModel <- createStillImageResource() + actualCreatedCopyright <- copyrightValueOption(createResourceResponseModel) + actualCreatedLicense <- licenseValueOption(createResourceResponseModel) + } yield assertTrue( + actualCreatedCopyright.isEmpty, + actualCreatedLicense.isEmpty, + ) + }, test( "when creating a resource with copyright attribution and license " + "the creation response should contain the license and copyright attribution", ) { for { - createResourceResponseModel <- createImageWithCopyrightAndLicense + createResourceResponseModel <- createStillImageResource(Some(aCopyrightAttribution), Some(aLicense)) actualCreatedCopyright <- copyrightValue(createResourceResponseModel) actualCreatedLicense <- licenseValue(createResourceResponseModel) } yield assertTrue( - actualCreatedCopyright == copyrightAttribution.value, - actualCreatedLicense == license.value, + actualCreatedCopyright == aCopyrightAttribution.value, + actualCreatedLicense == aLicense.value, ) }, test( @@ -56,14 +79,14 @@ object CopyrightAndLicensesSpec extends E2EZSpec { "the response when getting the created resource should contain the license and copyright attribution", ) { for { - createResourceResponseModel <- createImageWithCopyrightAndLicense + createResourceResponseModel <- createStillImageResource(Some(aCopyrightAttribution), Some(aLicense)) resourceId <- resourceId(createResourceResponseModel) getResponseModel <- getResourceFromApi(resourceId) actualCopyright <- copyrightValue(getResponseModel) actualLicense <- licenseValue(getResponseModel) } yield assertTrue( - actualCopyright == copyrightAttribution.value, - actualLicense == license.value, + actualCopyright == aCopyrightAttribution.value, + actualLicense == aLicense.value, ) }, test( @@ -71,51 +94,180 @@ object CopyrightAndLicensesSpec extends E2EZSpec { "the response when getting the created value should contain the license and copyright attribution", ) { for { - createResourceResponseModel <- createImageWithCopyrightAndLicense + createResourceResponseModel <- createStillImageResource(Some(aCopyrightAttribution), Some(aLicense)) + valueResponseModel <- getValueFromApi(createResourceResponseModel) + actualCopyright <- copyrightValue(valueResponseModel) + actualLicense <- licenseValue(valueResponseModel) + } yield assertTrue( + actualCopyright == aCopyrightAttribution.value, + actualLicense == aLicense.value, + ) + }, + test( + "when creating a resource without copyright attribution and license " + + "and when providing the project with copyright attribution and license " + + "and then updating the value" + + "the response when getting the updated value should contain the license and copyright attribution of the project", + ) { + for { + createResourceResponseModel <- createStillImageResource(None, None) + _ <- addCopyrightAttributionAndLicenseToProject() resourceId <- resourceId(createResourceResponseModel) valueId <- valueId(createResourceResponseModel) - valueResponseModel <- getValueFromApi(valueId, resourceId) + _ <- updateValue(resourceId, valueId) + valueGetResponse <- getValueFromApi(createResourceResponseModel) + actualCopyright <- copyrightValue(valueGetResponse) + actualLicense <- licenseValue(valueGetResponse) + } yield assertTrue( + actualCopyright == projectCopyrightAttribution.value, + actualLicense == projectLicense.value, + ) + }, + ) @@ TestAspect.before(removeCopyrightAttributionAndLicenseFromProject()) + + private val givenProjectHasCopyrightAttributionAndLicenseSuite = suite( + "given the project has a license and has a copyright attribution", + )( + test( + "when creating a resource without copyright attribution and without license " + + "then the response when getting the created value should contain the default license and default copyright attribution", + ) { + for { + createResourceResponseModel <- createStillImageResource() + valueResponseModel <- getValueFromApi(createResourceResponseModel) + actualCopyright <- copyrightValue(valueResponseModel) + actualLicense <- licenseValue(valueResponseModel) + } yield assertTrue( + actualCopyright == projectCopyrightAttribution.value, + actualLicense == projectLicense.value, + ) + }, + test( + "when creating a resource without copyright attribution and without license " + + "then the create response contain the license and copyright attribution from resource", + ) { + for { + createResourceResponseModel <- createStillImageResource() + actualCopyright <- copyrightValue(createResourceResponseModel) + actualLicense <- licenseValue(createResourceResponseModel) + } yield assertTrue( + actualCopyright == projectCopyrightAttribution.value, + actualLicense == projectLicense.value, + ) + }, + test( + "when creating a resource with copyright attribution and license " + + "then the create response contain the license and copyright attribution from resource", + ) { + for { + createResourceResponseModel <- createStillImageResource(Some(aCopyrightAttribution), Some(aLicense)) + actualCopyright <- copyrightValue(createResourceResponseModel) + actualLicense <- licenseValue(createResourceResponseModel) + } yield assertTrue( + actualCopyright == aCopyrightAttribution.value, + actualLicense == aLicense.value, + ) + }, + test( + "when creating a resource with copyright attribution and without license " + + "then the response when getting the created value should contain the license and copyright attribution from resource", + ) { + for { + createResourceResponseModel <- createStillImageResource(Some(aCopyrightAttribution), Some(aLicense)) + valueResponseModel <- getValueFromApi(createResourceResponseModel) actualCopyright <- copyrightValue(valueResponseModel) actualLicense <- licenseValue(valueResponseModel) } yield assertTrue( - actualCopyright == copyrightAttribution.value, - actualLicense == license.value, + actualCopyright == aCopyrightAttribution.value, + actualLicense == aLicense.value, ) }, + ) @@ TestAspect.before(addCopyrightAttributionAndLicenseToProject()) + + val e2eSpec: Spec[Scope & env, Any] = suite("Copyright Attribution and Licenses")( + givenProjectHasNoCopyrightAttributionAndLicenseSuite, + givenProjectHasCopyrightAttributionAndLicenseSuite, ) - private def createImageWithCopyrightAndLicense: ZIO[env, Throwable, Model] = { + private def removeCopyrightAttributionAndLicenseFromProject() = + setCopyrightAttributionAndLicenseToProject(None, None) + private def addCopyrightAttributionAndLicenseToProject() = + setCopyrightAttributionAndLicenseToProject(Some(projectCopyrightAttribution), Some(projectLicense)) + private def setCopyrightAttributionAndLicenseToProject( + copyrightAttribution: Option[CopyrightAttribution], + license: Option[License], + ) = + for { + projectService <- ZIO.service[KnoraProjectService] + prj <- projectService.findByShortcode(Shortcode.unsafeFrom("0001")).someOrFail(new Exception("Project not found")) + change = prj.copy(copyrightAttribution = copyrightAttribution, license = license) + updated <- projectService.save(change) + } yield updated + + private def failResponse(msg: String)(response: Response) = + response.body.asString.flatMap(bodyStr => ZIO.fail(Exception(s"$msg\nstatus: ${response.status}\nbody: $bodyStr"))) + + private def createStillImageResource( + copyrightAttribution: Option[CopyrightAttribution] = None, + license: Option[License] = None, + ): ZIO[env, Throwable, Model] = { val jsonLd = UploadFileRequest .make( FileType.StillImageFile(), "internalFilename.jpg", - copyrightAttribution = Some(copyrightAttribution), - license = Some(license), - ) - .toJsonLd( - className = Some("ThingPicture"), - ontologyName = "anything", + copyrightAttribution = copyrightAttribution, + license = license, ) - + .toJsonLd(className = Some("ThingPicture"), ontologyName = "anything") for { responseBody <- sendPostRequestAsRoot("/v2/resources", jsonLd) - .filterOrFail(_.status.isSuccess)(s"Failed to create resource") .mapError(Exception(_)) + .filterOrElseWith(_.status.isSuccess)(failResponse(s"Failed to create resource")) .flatMap(_.body.asString) createResourceResponseModel <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) } yield createResourceResponseModel } + private def updateValue(resourceIri: String, valueId: ValueIri) = { + val jsonLd = + s""" + |{ + | "@id": "${resourceIri}", + | "@type": "anything:ThingPicture", + | "knora-api:hasStillImageFileValue": { + | "@id" : "${valueId.smartIri.toComplexSchema.toIri}", + | "@type": "knora-api:StillImageFileValue", + | "knora-api:fileValueHasFilename": "test.jpg" + | }, + | "@context": { + | "knora-api": "http://api.knora.org/ontology/knora-api/v2#", + | "anything": "http://0.0.0.0:3333/ontology/0001/anything/v2#" + | } + |} + |""".stripMargin + for { + _ <- Console.printLine(jsonLd) + _ <- ModelOps.fromJsonLd(jsonLd).mapError(Exception(_)) + responseBody <- + sendPutRequestAsRoot("/v2/values", Body.fromString(jsonLd)) + .filterOrElseWith(_.status.isSuccess)(failResponse(s"Value update failed $valueId resource $resourceIri.")) + .flatMap(_.body.asString) + model <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) + } yield model + } + private def getResourceFromApi(resourceId: String) = for { responseBody <- sendGetRequest(s"/v2/resources/${URLEncoder.encode(resourceId, "UTF-8")}") - .filterOrFail(_.status.isSuccess)(s"Failed to get resource $resourceId") + .filterOrElseWith(_.status.isSuccess)(failResponse(s"Failed to get resource $resourceId.")) .flatMap(_.body.asString) model <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) } yield model - private def getValueFromApi(valueIri: ValueIri, resourceIri: String) = for { - responseBody <- sendGetRequest(s"/v2/values/${URLEncoder.encode(resourceIri, "UTF-8")}/${valueIri.valueId}") - .filterOrFail(_.status.isSuccess)(s"Failed to get resource $valueId") + private def getValueFromApi(createResourceResponse: Model) = for { + valueId <- valueId(createResourceResponse) + resourceId <- resourceId(createResourceResponse) + responseBody <- sendGetRequest(s"/v2/values/${URLEncoder.encode(resourceId, "UTF-8")}/${valueId.valueId}") + .filterOrElseWith(_.status.isSuccess)(failResponse(s"Failed to get value $resourceId.")) .flatMap(_.body.asString) model <- ModelOps.fromJsonLd(responseBody).mapError(Exception(_)) } yield model @@ -147,8 +299,20 @@ object CopyrightAndLicensesSpec extends E2EZSpec { case _ => ZIO.fail(Exception("Multiple values found")) } - private def copyrightValue(model: Model) = singleStringValue(model, HasCopyrightAttribution) - private def licenseValue(model: Model) = singleStringValue(model, HasLicense) - private def singleStringValue(model: Model, property: Property) = - ZIO.fromEither(model.singleSubjectWithProperty(property).flatMap(_.objectString(property))).mapError(Exception(_)) + private def copyrightValue(model: Model) = + singleStringValueOption(model, HasCopyrightAttribution).someOrFail(new Exception("No copyright found")) + private def copyrightValueOption(model: Model) = + singleStringValueOption(model, HasCopyrightAttribution) + private def licenseValue(model: Model) = + singleStringValueOption(model, HasLicense).someOrFail(new Exception("No license found")) + private def licenseValueOption(model: Model) = + singleStringValueOption(model, HasLicense) + private def singleStringValueOption(model: Model, property: Property): Task[Option[String]] = + ZIO + .fromEither( + model + .singleSubjectWithPropertyOption(property) + .flatMap(_.map(_.objectStringOption(property)).fold(Right(None))(identity)), + ) + .mapError(Exception(_)) } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7699bb8d5c..abf84db62c 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -19,6 +19,8 @@ object Dependencies { val PekkoActorVersion = "1.1.2" val PekkoHttpVersion = "1.1.0" + val MonocleVersion = "3.3.0" + // rdf and graph libraries // topbraid/shacl is not yet compatible with jena 5 so we need to use jena 4 for now // see: https://github.com/TopQuadrant/shacl/pull/177 @@ -53,6 +55,13 @@ object Dependencies { "dev.zio" %% "zio-json-interop-refined" % "0.7.3", ) + // monocle + val monocle = Seq( + "dev.optics" %% "monocle-core" % MonocleVersion, + "dev.optics" %% "monocle-macro" % MonocleVersion, + "dev.optics" %% "monocle-refined" % MonocleVersion, + ) + // zio-test and friends val zioTest = "dev.zio" %% "zio-test" % ZioVersion val zioTestSbt = "dev.zio" %% "zio-test-sbt" % ZioVersion @@ -149,7 +158,7 @@ object Dependencies { val webapiTestDependencies = Seq(zioTest, zioTestSbt, wiremock).map(_ % Test) - val webapiDependencies = refined ++ Seq( + val webapiDependencies = monocle ++ refined ++ Seq( pekkoActor, pekkoHttp, pekkoHttpCors, diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 45a29961ad..51713235f2 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -476,7 +476,8 @@ sealed trait SmartIri extends Ordered[SmartIri] with KnoraContentV2[SmartIri] { /** * Converts this IRI to ApiV2Complex schema. */ - def toComplexSchema: SmartIri = toOntologySchema(ApiV2Complex) + def toComplexSchema: SmartIri = toOntologySchema(ApiV2Complex) + def toInternalSchema: SmartIri = toOntologySchema(InternalSchema) /** * Constructs a short prefix label for the ontology that the IRI belongs to. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala index 8f44105d97..e226264b3b 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala @@ -26,6 +26,7 @@ import org.knora.webapi.messages.util.standoff.XMLUtil import org.knora.webapi.messages.v2.responder.* import org.knora.webapi.messages.v2.responder.standoffmessages.MappingXMLtoStandoff import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.messages.v2.responder.valuemessages.ValueMessagesV2Optics.* import org.knora.webapi.slice.admin.api.model.Project import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.Permission @@ -565,7 +566,6 @@ case class ReadResourceV2( values = valuesWithDeletedValues, ) } - } /** @@ -778,7 +778,7 @@ case class ReadResourcesSequenceV2( mayHaveMoreResults: Boolean = false, ) extends KnoraJsonLDResponseV2 with KnoraReadV2[ReadResourcesSequenceV2] - with UpdateResultInProject { + with UpdateResultInProject { self => override def toOntologySchema(targetSchema: ApiV2Schema): ReadResourcesSequenceV2 = copy( @@ -861,11 +861,12 @@ case class ReadResourcesSequenceV2( appConfig: AppConfig, schemaOptions: Set[Rendering] = Set.empty, ): JsonLDDocument = - toOntologySchema(targetSchema).generateJsonLD( - targetSchema = targetSchema, - appConfig = appConfig, - schemaOptions = schemaOptions, - ) + toOntologySchema(targetSchema) + .generateJsonLD( + targetSchema = targetSchema, + appConfig = appConfig, + schemaOptions = schemaOptions, + ) /** * Checks that a [[ReadResourcesSequenceV2]] contains exactly one resource, and returns that resource. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2Optics.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2Optics.scala new file mode 100644 index 0000000000..3ad3fb5a75 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2Optics.scala @@ -0,0 +1,63 @@ +/* + * 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.messages.v2.responder.resourcemessages + +import monocle.* +import monocle.macros.* + +import org.knora.webapi.messages.SmartIri +import org.knora.webapi.messages.v2.responder.valuemessages.FileValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.FileValueV2 +import org.knora.webapi.messages.v2.responder.valuemessages.ValueContentV2 +import org.knora.webapi.messages.v2.responder.valuemessages.ValueMessagesV2Optics.FileValueContentV2Optics + +object ResourceMessagesV2Optics { + + object CreateResourceV2Optics { + type CreateResourceV2Values = Map[SmartIri, Seq[CreateValueInNewResourceV2]] + + val values: Lens[CreateResourceV2, CreateResourceV2Values] = GenLens[CreateResourceV2](_.values) + + private def inValues(predicate: Seq[CreateValueInNewResourceV2] => Boolean) = + Optional[CreateResourceV2Values, Seq[CreateValueInNewResourceV2]](_.values.find(predicate))(newValue => + values => + values.map { + case (k, v) if predicate(v) => (k, newValue) + case other => other + }, + ) + + def values( + predicate: Seq[CreateValueInNewResourceV2] => Boolean, + ): Optional[CreateResourceV2, Seq[CreateValueInNewResourceV2]] = + values.andThen(inValues(predicate)) + } + + object CreateValueInNewResourceV2Optics { + + val valueContent: Lens[CreateValueInNewResourceV2, ValueContentV2] = + GenLens[CreateValueInNewResourceV2](_.valueContent) + + val fileValueContentV2: Optional[CreateValueInNewResourceV2, FileValueContentV2] = + Optional[CreateValueInNewResourceV2, FileValueContentV2](_.valueContent.asOpt[FileValueContentV2])(fc => + _.copy(valueContent = fc), + ) + + val fileValue: Optional[CreateValueInNewResourceV2, FileValueV2] = + CreateValueInNewResourceV2Optics.fileValueContentV2.andThen(FileValueContentV2Optics.fileValueV2) + + def elements( + predicate: CreateValueInNewResourceV2 => Boolean, + ): Optional[Seq[CreateValueInNewResourceV2], CreateValueInNewResourceV2] = + Optional[Seq[CreateValueInNewResourceV2], CreateValueInNewResourceV2](_.find(predicate))(newValue => + values => + values.map { + case v if predicate(v) => newValue + case other => other + }, + ) + } +} 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 88f0574fc7..4e0f3b35d3 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 @@ -39,6 +39,7 @@ 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.messages.v2.responder.valuemessages.ValueMessagesV2Optics.FileValueContentV2Optics import org.knora.webapi.routing.RouteUtilZ import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId import org.knora.webapi.slice.admin.api.model.Project @@ -525,6 +526,8 @@ sealed trait UpdateValueV2 { * A custom value creation date. */ val valueCreationDate: Option[Instant] + + def valueType: SmartIri } /** @@ -551,7 +554,9 @@ case class UpdateValueContentV2( permissions: Option[String] = None, valueCreationDate: Option[Instant] = None, newValueVersionIri: Option[SmartIri] = None, -) extends UpdateValueV2 +) extends UpdateValueV2 { + override def valueType: SmartIri = valueContent.valueType +} /** * New permissions for a value. @@ -669,6 +674,20 @@ sealed trait ValueContentV2 extends KnoraContentV2[ValueContentV2] with WithAsIs * Generates instances of value content classes (subclasses of [[ValueContentV2]]) from JSON-LD input. */ object ValueContentV2 { + def replaceCopyrightAndLicenceIfMissing( + license: Option[License], + copyrightAttribution: Option[CopyrightAttribution], + vc: ValueContentV2, + ): ValueContentV2 = vc match { + case fvc: FileValueContentV2 => + FileValueContentV2Optics.licenseOption + .filter(_.isEmpty) + .replace(license) + .andThen(FileValueContentV2Optics.copyRightAttributionOption.filter(_.isEmpty).replace(copyrightAttribution))( + fvc, + ) + case other => other + } final case class FileInfo(filename: IRI, metadata: FileMetadataSipiResponse) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2Optics.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2Optics.scala new file mode 100644 index 0000000000..0919d18093 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2Optics.scala @@ -0,0 +1,43 @@ +/* + * 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.messages.v2.responder.valuemessages + +import monocle.* +import monocle.macros.* + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.CopyrightAttribution +import org.knora.webapi.slice.admin.domain.model.KnoraProject.License + +object ValueMessagesV2Optics { + + object FileValueV2Optics { + + val copyrightAttributionOption: Lens[FileValueV2, Option[CopyrightAttribution]] = + GenLens[FileValueV2](_.copyrightAttribution) + + val licenseOption: Lens[FileValueV2, Option[License]] = + GenLens[FileValueV2](_.license) + + } + + object FileValueContentV2Optics { + + val fileValueV2: Lens[FileValueContentV2, FileValueV2] = + Lens[FileValueContentV2, FileValueV2](_.fileValue)(fv => { + case vc: MovingImageFileValueContentV2 => vc.copy(fileValue = fv) + case vc: StillImageFileValueContentV2 => vc.copy(fileValue = fv) + case vc: AudioFileValueContentV2 => vc.copy(fileValue = fv) + case vc: DocumentFileValueContentV2 => vc.copy(fileValue = fv) + case vc: StillImageExternalFileValueContentV2 => vc.copy(fileValue = fv) + case vc: ArchiveFileValueContentV2 => vc.copy(fileValue = fv) + case vc: TextFileValueContentV2 => vc.copy(fileValue = fv) + }) + val copyRightAttributionOption: Lens[FileValueContentV2, Option[CopyrightAttribution]] = + fileValueV2.andThen(FileValueV2Optics.copyrightAttributionOption) + val licenseOption: Lens[FileValueContentV2, Option[License]] = + fileValueV2.andThen(FileValueV2Optics.licenseOption) + } +} 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 e3ae11382c..4462869caa 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 @@ -653,7 +653,6 @@ final case class ResourcesResponderV2( } responseWithDeletedResourcesReplaced = apiResponse.copy(resources = deletedResourcesReplaced) } yield responseWithDeletedResourcesReplaced - } /** 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 06b66d73a0..1deecb50cf 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 @@ -32,17 +32,21 @@ import org.knora.webapi.messages.v2.responder.SuccessResponseV2 import org.knora.webapi.messages.v2.responder.ontologymessages.* import org.knora.webapi.messages.v2.responder.resourcemessages.* import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.messages.v2.responder.valuemessages.ValueMessagesV2Optics.FileValueContentV2Optics.* import org.knora.webapi.responders.IriLocker import org.knora.webapi.responders.IriService import org.knora.webapi.responders.admin.PermissionsResponder import org.knora.webapi.slice.admin.domain.model.Permission 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.KnoraProjectService import org.knora.webapi.slice.admin.domain.service.KnoraUserRepo import org.knora.webapi.slice.admin.domain.service.ProjectService +import org.knora.webapi.slice.common.KnoraIris.ResourceIri import org.knora.webapi.slice.ontology.domain.model.Cardinality.AtLeastOne import org.knora.webapi.slice.ontology.domain.model.Cardinality.ExactlyOne import org.knora.webapi.slice.ontology.domain.model.Cardinality.ZeroOrOne +import org.knora.webapi.slice.resourceinfo.domain.IriConverter 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 @@ -50,6 +54,8 @@ import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Update final case class ValuesResponderV2( appConfig: AppConfig, iriService: IriService, + iriConverter: IriConverter, + projectService: KnoraProjectService, messageRelay: MessageRelay, permissionUtilADM: PermissionUtilADM, resourceUtilV2: ResourceUtilV2, @@ -73,13 +79,25 @@ final case class ValuesResponderV2( ): Task[CreateValueResponseV2] = { def taskZio: Task[CreateValueResponseV2] = { for { + resourceIri <- + iriConverter + .asSmartIri(valueToCreate.resourceIri) + .flatMap(iri => ZIO.fromEither(ResourceIri.from(iri)).mapError(BadRequestException.apply)) + project <- projectService + .findByShortcode(resourceIri.shortcode) + .someOrFail(NotFoundException(s"Project not found for resource IRI: $resourceIri")) + // Convert the submitted value to the internal schema. submittedInternalPropertyIri <- ZIO.attempt(valueToCreate.propertyIri.toOntologySchema(InternalSchema)) - submittedInternalValueContent: ValueContentV2 = - valueToCreate.valueContent - .toOntologySchema(InternalSchema) + submittedInternalValueContent = ValueContentV2 + .replaceCopyrightAndLicenceIfMissing( + project.license, + project.copyrightAttribution, + valueToCreate.valueContent, + ) + .toOntologySchema(InternalSchema) // Get ontology information about the submitted property. propertyInfoRequestForSubmittedProperty = @@ -307,7 +325,6 @@ final case class ValuesResponderV2( projectADM = resourceInfo.projectADM, ) } - for { // Don't allow anonymous users to create values. _ <- ZIO.when(requestingUser.isAnonymousUser)( @@ -555,408 +572,355 @@ final case class ValuesResponderV2( updateValue: UpdateValueV2, requestingUser: User, apiRequestId: UUID, - ): Task[UpdateValueResponseV2] = { + ): Task[UpdateValueResponseV2] = + ZIO + .fail(ForbiddenException("Anonymous users aren't allowed to update values")) + .when(requestingUser.isAnonymousUser) *> + IriLocker.runWithIriLock( + apiRequestId, + updateValue.resourceIri, + updateValue match { + case updateContent: UpdateValueContentV2 => updateValueContent(updateContent, requestingUser) + case updatePermissions: UpdateValuePermissionsV2 => updateValuePermissions(updatePermissions, requestingUser) + }, + ) - /** - * Information about a resource, a submitted property, and a value of the property. - * - * @param resource the contents of the resource. - * @param submittedInternalPropertyIri the internal IRI of the submitted property. - * @param adjustedInternalPropertyInfo the internal definition of the submitted property, adjusted - * as follows: an adjusted version of the submitted property: - * if it's a link value property, substitute the - * corresponding link property. - * @param value the requested value. - */ - case class ResourcePropertyValue( - resource: ReadResourceV2, - submittedInternalPropertyIri: SmartIri, - adjustedInternalPropertyInfo: ReadPropertyInfoV2, - value: ReadValueV2, - ) + /** + * Updates the permissions attached to a value. + * + * @param updateValue the update request. + * @return an [[UpdateValueResponseV2]]. + */ + private def updateValuePermissions(updateValue: UpdateValuePermissionsV2, requestingUser: User) = + for { + // Do the initial checks, and get information about the resource, the property, and the value. + resourcePropertyValue <- checkValueAndRetrieveResourceProperties(updateValue, requestingUser) + + resourceInfo: ReadResourceV2 = resourcePropertyValue.resource + currentValue: ReadValueV2 = resourcePropertyValue.value + + // Validate and reformat the submitted permissions. + newValuePermissionLiteral <- permissionUtilADM.validatePermissions(updateValue.permissions) + + // Check that the user has Permission.ObjectAccess.ChangeRights on the value, and that the new permissions are + // different from the current ones. + currentPermissionsParsed <- ZIO.attempt(PermissionUtilADM.parsePermissions(currentValue.permissions)) + newPermissionsParsed <- + ZIO.attempt( + PermissionUtilADM.parsePermissions( + updateValue.permissions, + (permissionLiteral: String) => throw AssertionException(s"Invalid permission literal: $permissionLiteral"), + ), + ) - /** - * Gets information about a resource, a submitted property, and a value of the property, and does - * some checks to see if the submitted information is correct. - * - * @param resourceIri the IRI of the resource. - * @param submittedExternalResourceClassIri the submitted external IRI of the resource class. - * @param submittedExternalPropertyIri the submitted external IRI of the property. - * @param valueIri the IRI of the value. - * @param submittedExternalValueType the submitted external IRI of the value type. - * @return a [[ResourcePropertyValue]]. - */ - def getResourcePropertyValue( - resourceIri: IRI, - submittedExternalResourceClassIri: SmartIri, - submittedExternalPropertyIri: SmartIri, - valueIri: IRI, - submittedExternalValueType: SmartIri, - ): Task[ResourcePropertyValue] = - for { - submittedInternalPropertyIri <- ZIO.attempt(submittedExternalPropertyIri.toOntologySchema(InternalSchema)) - submittedInternalValueType <- ZIO.attempt(submittedExternalValueType.toOntologySchema(InternalSchema)) + _ <- ZIO.when(newPermissionsParsed == currentPermissionsParsed)( + ZIO.fail(BadRequestException(s"The submitted permissions are the same as the current ones")), + ) - // Get ontology information about the submitted property. - propertyInfoRequestForSubmittedProperty = - PropertiesGetRequestV2( - propertyIris = Set(submittedInternalPropertyIri), - allLanguages = false, - requestingUser = requestingUser, - ) + _ <- resourceUtilV2.checkValuePermission( + resourceInfo = resourceInfo, + valueInfo = currentValue, + permissionNeeded = Permission.ObjectAccess.ChangeRights, + requestingUser = requestingUser, + ) - propertyInfoResponseForSubmittedProperty <- - messageRelay.ask[ReadOntologyV2](propertyInfoRequestForSubmittedProperty) + // Do the update. + dataNamedGraph: IRI = ProjectService.projectDataNamedGraphV2(resourceInfo.projectADM).value + newValueIri <- + iriService.checkOrCreateEntityIri( + updateValue.newValueVersionIri, + stringFormatter.makeRandomValueIri(resourceInfo.resourceIri), + ) - propertyInfoForSubmittedProperty: ReadPropertyInfoV2 = - propertyInfoResponseForSubmittedProperty.properties( - submittedInternalPropertyIri, - ) + currentTime = updateValue.valueCreationDate.getOrElse(Instant.now) - // Don't accept link properties. - _ <- - ZIO.when(propertyInfoForSubmittedProperty.isLinkProp)( - ZIO.fail( - BadRequestException( - s"Invalid property <${propertyInfoForSubmittedProperty.entityInfoContent.propertyIri.toOntologySchema(ApiV2Complex)}>. Use a link value property to submit a link.", - ), - ), - ) + sparqlUpdate = sparql.v2.txt.changeValuePermissions( + dataNamedGraph = dataNamedGraph, + resourceIri = resourceInfo.resourceIri, + propertyIri = updateValue.propertyIri.toInternalSchema, + currentValueIri = currentValue.valueIri, + valueTypeIri = currentValue.valueContent.valueType, + newValueIri = newValueIri, + newPermissions = newValuePermissionLiteral, + currentTime = currentTime, + ) + _ <- triplestoreService.query(Update(sparqlUpdate)) + } yield UpdateValueResponseV2( + newValueIri, + currentValue.valueContent.valueType, + currentValue.valueHasUUID, + resourceInfo.projectADM, + ) - // Don't accept knora-api:hasStandoffLinkToValue. - _ <- ZIO.when( - submittedExternalPropertyIri.toString == OntologyConstants.KnoraApiV2Complex.HasStandoffLinkToValue, - )(ZIO.fail(BadRequestException(s"Values of <$submittedExternalPropertyIri> cannot be updated directly"))) + /** + * Updates the contents of a value. + * + * @param updateValue the update request. + * @return an [[UpdateValueResponseV2]]. + */ + private def updateValueContent( + updateValue: UpdateValueContentV2, + requestingUser: User, + ): Task[UpdateValueResponseV2] = { + for { + resourcePropertyValue <- checkValueAndRetrieveResourceProperties(updateValue, requestingUser) - // Make an adjusted version of the submitted property: if it's a link value property, substitute the - // corresponding link property, whose objects we will need to query. Get ontology information about the - // adjusted property. - adjustedInternalPropertyInfo <- - getAdjustedInternalPropertyInfo( - submittedPropertyIri = submittedExternalPropertyIri, - maybeSubmittedValueType = Some(submittedExternalValueType), - propertyInfoForSubmittedProperty = propertyInfoForSubmittedProperty, - requestingUser = requestingUser, - ) + resourceInfo: ReadResourceV2 = resourcePropertyValue.resource + adjustedInternalPropertyInfo: ReadPropertyInfoV2 = resourcePropertyValue.adjustedInternalPropertyInfo + currentValue: ReadValueV2 = resourcePropertyValue.value - // Get the resource's metadata and relevant property objects, using the adjusted property. Do this as the system user, - // so we can see objects that the user doesn't have permission to see. - resourceInfo <- - getResourceWithPropertyValues( - resourceIri = resourceIri, - propertyInfo = adjustedInternalPropertyInfo, - requestingUser = KnoraSystemInstances.Users.SystemUser, - ) + // Did the user submit permissions for the new value? + newValueVersionPermissionLiteral <- + updateValue.permissions match { + case Some(permissions) => + // Yes. Validate them. + permissionUtilADM.validatePermissions(permissions) - _ <- - ZIO.when(resourceInfo.resourceClassIri != submittedExternalResourceClassIri.toOntologySchema(InternalSchema))( - ZIO.fail( - BadRequestException( - s"The rdf:type of resource <$resourceIri> is not <$submittedExternalResourceClassIri>", - ), - ), - ) + case None => + // No. Use the permissions on the current version of the value. + ZIO.succeed(currentValue.permissions) + } - // Check that the resource has the value that the user wants to update, as an object of the submitted property. - currentValue <- - ZIO - .fromOption(for { - values <- resourceInfo.values.get(submittedInternalPropertyIri) - curVal <- values.find(_.valueIri == valueIri) - } yield curVal) - .orElseFail( - NotFoundException( - s"Resource <$resourceIri> does not have value <$valueIri> as an object of property <$submittedExternalPropertyIri>", - ), - ) - isSameType = currentValue.valueContent.valueType == submittedInternalValueType - isStillImageTypes = - Set( - submittedInternalValueType.toInternalIri.value, - currentValue.valueContent.valueType.toInternalIri.value, - ).subsetOf(Set(StillImageExternalFileValue, StillImageFileValue)) - _ <- - ZIO.unless(isSameType || isStillImageTypes)( - ZIO.fail( - BadRequestException( - s"Value <$valueIri> has type <${currentValue.valueContent.valueType.toOntologySchema(ApiV2Complex)}>, but the submitted type was <$submittedExternalValueType>", - ), - ), - ) + // Check that the user has permission to do the update. If they want to change the permissions + // on the value, they need Permission.ObjectAccess.ChangeRights, otherwise they need Permission.ObjectAccess.Modify. + currentPermissionsParsed <- ZIO.attempt(PermissionUtilADM.parsePermissions(currentValue.permissions)) + newPermissionsParsed <- + ZIO.attempt( + PermissionUtilADM.parsePermissions( + newValueVersionPermissionLiteral, + (permissionLiteral: String) => throw AssertionException(s"Invalid permission literal: $permissionLiteral"), + ), + ) - // If a custom value creation date was submitted, make sure it's later than the date of the current version. - _ <- ZIO.when(updateValue.valueCreationDate.exists(!_.isAfter(currentValue.valueCreationDate)))( - ZIO.fail( - BadRequestException( - "A custom value creation date must be later than the date of the current version", - ), - ), - ) - } yield ResourcePropertyValue( - resourceInfo, - submittedInternalPropertyIri, - adjustedInternalPropertyInfo, - currentValue, - ) + permissionNeeded = + if (newPermissionsParsed != currentPermissionsParsed) { Permission.ObjectAccess.ChangeRights } + else { Permission.ObjectAccess.Modify } - /** - * Updates the permissions attached to a value. - * - * @param updateValuePermissionsV2 the update request. - * @return an [[UpdateValueResponseV2]]. - */ - def makeTaskFutureToUpdateValuePermissions( - updateValuePermissionsV2: UpdateValuePermissionsV2, - ): Task[UpdateValueResponseV2] = - for { - // Do the initial checks, and get information about the resource, the property, and the value. - resourcePropertyValue <- - getResourcePropertyValue( - resourceIri = updateValuePermissionsV2.resourceIri, - submittedExternalResourceClassIri = updateValuePermissionsV2.resourceClassIri, - submittedExternalPropertyIri = updateValuePermissionsV2.propertyIri, - valueIri = updateValuePermissionsV2.valueIri, - submittedExternalValueType = updateValuePermissionsV2.valueType, - ) + _ <- resourceUtilV2.checkValuePermission( + resourceInfo = resourceInfo, + valueInfo = currentValue, + permissionNeeded = permissionNeeded, + requestingUser = requestingUser, + ) - resourceInfo: ReadResourceV2 = resourcePropertyValue.resource - submittedInternalPropertyIri: SmartIri = resourcePropertyValue.submittedInternalPropertyIri - currentValue: ReadValueV2 = resourcePropertyValue.value - - // Validate and reformat the submitted permissions. - newValuePermissionLiteral <- permissionUtilADM.validatePermissions(updateValuePermissionsV2.permissions) - - // Check that the user has Permission.ObjectAccess.ChangeRights on the value, and that the new permissions are - // different from the current ones. - currentPermissionsParsed <- ZIO.attempt(PermissionUtilADM.parsePermissions(currentValue.permissions)) - newPermissionsParsed <- - ZIO.attempt( - PermissionUtilADM.parsePermissions( - updateValuePermissionsV2.permissions, - (permissionLiteral: String) => throw AssertionException(s"Invalid permission literal: $permissionLiteral"), - ), - ) + // Convert the submitted value content to the internal schema. + project = resourceInfo.projectADM + submittedInternalValueContent = + ValueContentV2 + .replaceCopyrightAndLicenceIfMissing(project.license, project.copyrightAttribution, updateValue.valueContent) + .toOntologySchema(InternalSchema) + + // Check that the object of the adjusted property (the value to be created, or the target of the link to be created) will have + // the correct type for the adjusted property's knora-base:objectClassConstraint. + _ <- checkPropertyObjectClassConstraint( + propertyInfo = adjustedInternalPropertyInfo, + valueContent = submittedInternalValueContent, + requestingUser = requestingUser, + ) - _ <- ZIO.when(newPermissionsParsed == currentPermissionsParsed)( - ZIO.fail(BadRequestException(s"The submitted permissions are the same as the current ones")), - ) + _ <- ifIsListValueThenCheckItPointsToListNodeWhichIsNotARootNode(submittedInternalValueContent) - _ <- resourceUtilV2.checkValuePermission( - resourceInfo = resourceInfo, - valueInfo = currentValue, - permissionNeeded = Permission.ObjectAccess.ChangeRights, - requestingUser = requestingUser, - ) + // Check that the updated value would not duplicate the current value version. + unescapedSubmittedInternalValueContent = submittedInternalValueContent.unescape - // Do the update. - dataNamedGraph: IRI = ProjectService.projectDataNamedGraphV2(resourceInfo.projectADM).value - newValueIri <- - iriService.checkOrCreateEntityIri( - updateValuePermissionsV2.newValueVersionIri, - stringFormatter.makeRandomValueIri(resourceInfo.resourceIri), - ) + _ <- ZIO.when(unescapedSubmittedInternalValueContent.wouldDuplicateCurrentVersion(currentValue.valueContent))( + ZIO.fail(DuplicateValueException("The submitted value is the same as the current version")), + ) - currentTime = updateValuePermissionsV2.valueCreationDate.getOrElse(Instant.now) + // Check that the updated value would not duplicate another existing value of the resource. + currentValuesForProp: Seq[ReadValueV2] = + resourceInfo.values + .getOrElse(updateValue.propertyIri.toInternalSchema, Seq.empty[ReadValueV2]) + .filter(_.valueIri != updateValue.valueIri) + + _ <- ZIO.when( + currentValuesForProp.exists(currentVal => + unescapedSubmittedInternalValueContent.wouldDuplicateOtherValue(currentVal.valueContent), + ), + )(ZIO.fail(DuplicateValueException())) + + _ <- submittedInternalValueContent match { + case textValueContent: TextValueContentV2 => + // This is a text value. Check that the resources pointed to by any standoff link tags exist + // and that the user has permission to see them. + checkResourceIris( + textValueContent.standoffLinkTagTargetResourceIris, + requestingUser, + ) - sparqlUpdate = sparql.v2.txt.changeValuePermissions( - dataNamedGraph = dataNamedGraph, - resourceIri = resourceInfo.resourceIri, - propertyIri = submittedInternalPropertyIri, - currentValueIri = currentValue.valueIri, - valueTypeIri = currentValue.valueContent.valueType, - newValueIri = newValueIri, - newPermissions = newValuePermissionLiteral, - currentTime = currentTime, - ) - _ <- triplestoreService.query(Update(sparqlUpdate)) - } yield UpdateValueResponseV2( - newValueIri, - currentValue.valueContent.valueType, - currentValue.valueHasUUID, - resourceInfo.projectADM, - ) + case _: LinkValueContentV2 => + // We're updating a link. This means deleting an existing link and creating a new one, so + // check that the user has permission to modify the resource. + resourceUtilV2.checkResourcePermission( + resourceInfo = resourceInfo, + permissionNeeded = Permission.ObjectAccess.Modify, + requestingUser = requestingUser, + ) - /** - * Updates the contents of a value. - * - * @param updateValueContentV2 the update request. - * @return an [[UpdateValueResponseV2]]. - */ - def makeTaskFutureToUpdateValueContent( - updateValueContentV2: UpdateValueContentV2, - ): Task[UpdateValueResponseV2] = { - for { - // Do the initial checks, and get information about the resource, the property, and the value. - resourcePropertyValue <- - getResourcePropertyValue( - resourceIri = updateValueContentV2.resourceIri, - submittedExternalResourceClassIri = updateValueContentV2.resourceClassIri, - submittedExternalPropertyIri = updateValueContentV2.propertyIri, - valueIri = updateValueContentV2.valueIri, - submittedExternalValueType = updateValueContentV2.valueContent.valueType, - ) + case _ => ZIO.unit + } - resourceInfo: ReadResourceV2 = resourcePropertyValue.resource - submittedInternalPropertyIri: SmartIri = resourcePropertyValue.submittedInternalPropertyIri - adjustedInternalPropertyInfo: ReadPropertyInfoV2 = resourcePropertyValue.adjustedInternalPropertyInfo - currentValue: ReadValueV2 = resourcePropertyValue.value + dataNamedGraph: IRI = ProjectService.projectDataNamedGraphV2(resourceInfo.projectADM).value - // Did the user submit permissions for the new value? - newValueVersionPermissionLiteral <- - updateValueContentV2.permissions match { - case Some(permissions) => - // Yes. Validate them. - permissionUtilADM.validatePermissions(permissions) + // Create the new value version. + newValueVersion <- + (currentValue, submittedInternalValueContent) match { + case ( + currentLinkValue: ReadLinkValueV2, + newLinkValue: LinkValueContentV2, + ) => + updateLinkValueV2AfterChecks( + dataNamedGraph = dataNamedGraph, + resourceInfo = resourceInfo, + linkPropertyIri = adjustedInternalPropertyInfo.entityInfoContent.propertyIri, + currentLinkValue = currentLinkValue, + newLinkValue = newLinkValue, + valueCreator = requestingUser.id, + valuePermissions = newValueVersionPermissionLiteral, + valueCreationDate = updateValue.valueCreationDate, + newValueVersionIri = updateValue.newValueVersionIri, + requestingUser = requestingUser, + ) - case None => - // No. Use the permissions on the current version of the value. - ZIO.succeed(currentValue.permissions) - } + case _ => + updateOrdinaryValueV2AfterChecks( + dataNamedGraph = dataNamedGraph, + resourceInfo = resourceInfo, + propertyIri = adjustedInternalPropertyInfo.entityInfoContent.propertyIri, + currentValue = currentValue, + newValueVersion = submittedInternalValueContent, + valueCreator = requestingUser.id, + valuePermissions = newValueVersionPermissionLiteral, + valueCreationDate = updateValue.valueCreationDate, + newValueVersionIri = updateValue.newValueVersionIri, + requestingUser = requestingUser, + ) + } + } yield UpdateValueResponseV2( + valueIri = newValueVersion.newValueIri, + valueType = newValueVersion.valueContent.valueType, + valueUUID = newValueVersion.newValueUUID, + projectADM = resourceInfo.projectADM, + ) + } - // Check that the user has permission to do the update. If they want to change the permissions - // on the value, they need Permission.ObjectAccess.ChangeRights, otherwise they need Permission.ObjectAccess.Modify. - currentPermissionsParsed <- ZIO.attempt(PermissionUtilADM.parsePermissions(currentValue.permissions)) - newPermissionsParsed <- - ZIO.attempt( - PermissionUtilADM.parsePermissions( - newValueVersionPermissionLiteral, - (permissionLiteral: String) => throw AssertionException(s"Invalid permission literal: $permissionLiteral"), - ), - ) + /** + * Information about a resource, a submitted property, and a value of the property. + * + * @param resource the contents of the resource. + * @param adjustedInternalPropertyInfo the internal definition of the submitted property, adjusted + * as follows: an adjusted version of the submitted property: + * if it's a link value property, substitute the + * corresponding link property. + * @param value the requested value. + */ + private case class ResourcePropertyValue( + resource: ReadResourceV2, + adjustedInternalPropertyInfo: ReadPropertyInfoV2, + value: ReadValueV2, + ) - permissionNeeded = - if (newPermissionsParsed != currentPermissionsParsed) { Permission.ObjectAccess.ChangeRights } - else { Permission.ObjectAccess.Modify } + /** + * Gets information about a resource, a submitted property, and a value of the property, and does + * some checks to see if the submitted information is correct. + * + * @param updateValue the submitted value update to check + * @return a [[ResourcePropertyValue]]. + */ + private def checkValueAndRetrieveResourceProperties(updateValue: UpdateValueV2, requestingUser: User) = + for { + submittedInternalPropertyIri <- ZIO.attempt(updateValue.propertyIri.toInternalSchema) + submittedInternalValueType <- ZIO.attempt(updateValue.valueType.toInternalSchema) + + // Get ontology information about the submitted property. + propertyInfoRequestForSubmittedProperty = + PropertiesGetRequestV2( + propertyIris = Set(submittedInternalPropertyIri), + allLanguages = false, + requestingUser = requestingUser, + ) - _ <- resourceUtilV2.checkValuePermission( - resourceInfo = resourceInfo, - valueInfo = currentValue, - permissionNeeded = permissionNeeded, - requestingUser = requestingUser, - ) + propertyInfoResponseForSubmittedProperty <- + messageRelay.ask[ReadOntologyV2](propertyInfoRequestForSubmittedProperty) - // Convert the submitted value content to the internal schema. - submittedInternalValueContent: ValueContentV2 = - updateValueContentV2.valueContent.toOntologySchema( - InternalSchema, - ) + propertyInfoForSubmittedProperty: ReadPropertyInfoV2 = + propertyInfoResponseForSubmittedProperty.properties(submittedInternalPropertyIri) - // Check that the object of the adjusted property (the value to be created, or the target of the link to be created) will have - // the correct type for the adjusted property's knora-base:objectClassConstraint. - _ <- checkPropertyObjectClassConstraint( - propertyInfo = adjustedInternalPropertyInfo, - valueContent = submittedInternalValueContent, - requestingUser = requestingUser, - ) + _ <- { + val msg = + s"Invalid property <${propertyInfoForSubmittedProperty.entityInfoContent.propertyIri.toComplexSchema}>." + + s" Use a link value property to submit a link." + ZIO.fail(BadRequestException(msg)).when(propertyInfoForSubmittedProperty.isLinkProp) + } - _ <- ifIsListValueThenCheckItPointsToListNodeWhichIsNotARootNode(submittedInternalValueContent) + // Don't accept knora-api:hasStandoffLinkToValue. + _ <- ZIO.when( + updateValue.propertyIri.toString == OntologyConstants.KnoraApiV2Complex.HasStandoffLinkToValue, + )(ZIO.fail(BadRequestException(s"Values of <${updateValue.propertyIri}> cannot be updated directly"))) + + // Make an adjusted version of the submitted property: if it's a link value property, substitute the + // corresponding link property, whose objects we will need to query. Get ontology information about the + // adjusted property. + adjustedInternalPropertyInfo <- getAdjustedInternalPropertyInfo( + updateValue.propertyIri, + Some(updateValue.valueType), + propertyInfoForSubmittedProperty, + requestingUser, + ) + + // Get the resource's metadata and relevant property objects, using the adjusted property. Do this as the system user, + // so we can see objects that the user doesn't have permission to see. + resourceInfo <- + getResourceWithPropertyValues( + resourceIri = updateValue.resourceIri, + propertyInfo = adjustedInternalPropertyInfo, + requestingUser = KnoraSystemInstances.Users.SystemUser, + ) - // Check that the updated value would not duplicate the current value version. - unescapedSubmittedInternalValueContent = submittedInternalValueContent.unescape + _ <- { + val msg = s"The rdf:type of resource <${updateValue.resourceIri}> is not <${updateValue.resourceClassIri}>" + ZIO + .fail(BadRequestException(msg)) + .when(resourceInfo.resourceClassIri != updateValue.resourceClassIri.toInternalSchema) + } - _ <- ZIO.when(unescapedSubmittedInternalValueContent.wouldDuplicateCurrentVersion(currentValue.valueContent))( - ZIO.fail(DuplicateValueException("The submitted value is the same as the current version")), - ) + // Check that the resource has the value that the user wants to update, as an object of the submitted property. + currentValue <- + ZIO + .fromOption(for { + values <- resourceInfo.values.get(submittedInternalPropertyIri) + curVal <- values.find(_.valueIri == updateValue.valueIri) + } yield curVal) + .orElseFail( + NotFoundException( + s"Resource <${updateValue.resourceIri}> does not have value <${updateValue.valueIri}> as an object of property <${updateValue.propertyIri}>", + ), + ) + isSameType = currentValue.valueContent.valueType == submittedInternalValueType + isStillImageTypes = + Set( + submittedInternalValueType.toInternalIri.value, + currentValue.valueContent.valueType.toInternalIri.value, + ).subsetOf(Set(StillImageExternalFileValue, StillImageFileValue)) - // Check that the updated value would not duplicate another existing value of the resource. - currentValuesForProp: Seq[ReadValueV2] = - resourceInfo.values - .getOrElse(submittedInternalPropertyIri, Seq.empty[ReadValueV2]) - .filter(_.valueIri != updateValueContentV2.valueIri) + _ <- + ZIO.unless(isSameType || isStillImageTypes)( + ZIO.fail( + BadRequestException( + s"Value <${updateValue.valueIri}> has type <${currentValue.valueContent.valueType.toOntologySchema(ApiV2Complex)}>, but the submitted type was <${updateValue.valueType}>", + ), + ), + ) - _ <- ZIO.when( - currentValuesForProp.exists(currentVal => - unescapedSubmittedInternalValueContent.wouldDuplicateOtherValue(currentVal.valueContent), + // If a custom value creation date was submitted, make sure it's later than the date of the current version. + _ <- ZIO.when(updateValue.valueCreationDate.exists(!_.isAfter(currentValue.valueCreationDate)))( + ZIO.fail( + BadRequestException( + "A custom value creation date must be later than the date of the current version", ), - )(ZIO.fail(DuplicateValueException())) - - _ <- submittedInternalValueContent match { - case textValueContent: TextValueContentV2 => - // This is a text value. Check that the resources pointed to by any standoff link tags exist - // and that the user has permission to see them. - checkResourceIris( - textValueContent.standoffLinkTagTargetResourceIris, - requestingUser, - ) - - case _: LinkValueContentV2 => - // We're updating a link. This means deleting an existing link and creating a new one, so - // check that the user has permission to modify the resource. - resourceUtilV2.checkResourcePermission( - resourceInfo = resourceInfo, - permissionNeeded = Permission.ObjectAccess.Modify, - requestingUser = requestingUser, - ) - - case _ => ZIO.unit - } - - dataNamedGraph: IRI = ProjectService.projectDataNamedGraphV2(resourceInfo.projectADM).value - - // Create the new value version. - newValueVersion <- - (currentValue, submittedInternalValueContent) match { - case ( - currentLinkValue: ReadLinkValueV2, - newLinkValue: LinkValueContentV2, - ) => - updateLinkValueV2AfterChecks( - dataNamedGraph = dataNamedGraph, - resourceInfo = resourceInfo, - linkPropertyIri = adjustedInternalPropertyInfo.entityInfoContent.propertyIri, - currentLinkValue = currentLinkValue, - newLinkValue = newLinkValue, - valueCreator = requestingUser.id, - valuePermissions = newValueVersionPermissionLiteral, - valueCreationDate = updateValueContentV2.valueCreationDate, - newValueVersionIri = updateValueContentV2.newValueVersionIri, - requestingUser = requestingUser, - ) - - case _ => - updateOrdinaryValueV2AfterChecks( - dataNamedGraph = dataNamedGraph, - resourceInfo = resourceInfo, - propertyIri = adjustedInternalPropertyInfo.entityInfoContent.propertyIri, - currentValue = currentValue, - newValueVersion = submittedInternalValueContent, - valueCreator = requestingUser.id, - valuePermissions = newValueVersionPermissionLiteral, - valueCreationDate = updateValueContentV2.valueCreationDate, - newValueVersionIri = updateValueContentV2.newValueVersionIri, - requestingUser = requestingUser, - ) - } - } yield UpdateValueResponseV2( - valueIri = newValueVersion.newValueIri, - valueType = newValueVersion.valueContent.valueType, - valueUUID = newValueVersion.newValueUUID, - projectADM = resourceInfo.projectADM, - ) - } - - if (requestingUser.isAnonymousUser) { - ZIO.fail(ForbiddenException("Anonymous users aren't allowed to update values")) - } else { - updateValue match { - case updateValueContentV2: UpdateValueContentV2 => - // This is a request to update the content of a value. - IriLocker.runWithIriLock( - apiRequestId, - updateValueContentV2.resourceIri, - makeTaskFutureToUpdateValueContent(updateValueContentV2), - ) - - case updateValuePermissionsV2: UpdateValuePermissionsV2 => - // This is a request to update the permissions attached to a value. - IriLocker.runWithIriLock( - apiRequestId, - updateValuePermissionsV2.resourceIri, - makeTaskFutureToUpdateValuePermissions(updateValuePermissionsV2), - ) - } - } - } + ), + ) + } yield ResourcePropertyValue(resourceInfo, adjustedInternalPropertyInfo, currentValue) /** * Changes an ordinary value (i.e. not a link), assuming that pre-update checks have already been done. @@ -1054,6 +1018,11 @@ final case class ValuesResponderV2( currentTime: Instant = valueCreationDate.getOrElse(Instant.now) // Generate a SPARQL update. + newValue: ValueContentV2 = ValueContentV2.replaceCopyrightAndLicenceIfMissing( + resourceInfo.projectADM.license, + resourceInfo.projectADM.copyrightAttribution, + newValueVersion, + ) sparqlUpdate = sparql.v2.txt.addValueVersion( dataNamedGraph = dataNamedGraph, resourceIri = resourceInfo.resourceIri, @@ -1061,10 +1030,10 @@ final case class ValuesResponderV2( currentValueIri = currentValue.valueIri, newValueIri = newValueIri, valueTypeIri = currentValue.valueContent.valueType, - value = newValueVersion, + value = newValue, valueCreator = valueCreator, valuePermissions = valuePermissions, - maybeComment = newValueVersion.comment, + maybeComment = newValue.comment, linkUpdates = standoffLinkUpdates, currentTime = currentTime, requestingUser = requestingUser.id, @@ -1076,7 +1045,7 @@ final case class ValuesResponderV2( } yield UnverifiedValueV2( newValueIri = newValueIri, newValueUUID = currentValue.valueHasUUID, - valueContent = newValueVersion.unescape, + valueContent = newValue.unescape, permissions = valuePermissions, creationDate = currentTime, ) 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 0a42465cab..ad1931c97e 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 @@ -6,9 +6,11 @@ package org.knora.webapi.responders.v2.resources import com.typesafe.scalalogging.LazyLogging +import monocle.Optional import zio.* import java.time.Instant +import scala.language.postfixOps import dsp.errors.* import dsp.valueobjects.UuidUtil @@ -28,8 +30,11 @@ import org.knora.webapi.messages.v2.responder.ontologymessages.EntityInfoGetResp import org.knora.webapi.messages.v2.responder.ontologymessages.OwlCardinality.* import org.knora.webapi.messages.v2.responder.ontologymessages.ReadClassInfoV2 import org.knora.webapi.messages.v2.responder.resourcemessages.* +import org.knora.webapi.messages.v2.responder.resourcemessages.ResourceMessagesV2Optics.CreateResourceV2Optics +import org.knora.webapi.messages.v2.responder.resourcemessages.ResourceMessagesV2Optics.CreateValueInNewResourceV2Optics import org.knora.webapi.messages.v2.responder.standoffmessages.* import org.knora.webapi.messages.v2.responder.valuemessages.* +import org.knora.webapi.messages.v2.responder.valuemessages.ValueMessagesV2Optics.FileValueV2Optics import org.knora.webapi.responders.IriLocker import org.knora.webapi.responders.IriService import org.knora.webapi.responders.admin.PermissionsResponder @@ -85,6 +90,27 @@ final case class CreateResourceV2Handler( def apply(createResourceRequestV2: CreateResourceRequestV2): Task[ReadResourcesSequenceV2] = triplestoreUpdate(createResourceRequestV2) + private def replaceCopyrightAttributionAndLicenseIfMissing(project: Project): CreateResourceV2 => CreateResourceV2 = { + def createValuesWith( + pred: FileValueV2 => Boolean, + ): Optional[Seq[CreateValueInNewResourceV2], CreateValueInNewResourceV2] = + CreateValueInNewResourceV2Optics.elements(cv => + CreateValueInNewResourceV2Optics.fileValue.getOption(cv).exists(pred), + ) + + def fileValueWith(pred: FileValueV2 => Boolean): Optional[CreateResourceV2, FileValueV2] = + CreateResourceV2Optics + .values(createValuesWith(pred).getOption(_).isDefined) + .andThen(createValuesWith(pred)) + .andThen(CreateValueInNewResourceV2Optics.fileValue) + + def replaceIfEmpty[T](newValue: Option[T], opt: Optional[FileValueV2, Option[T]]) = + fileValueWith(opt.getOption(_).flatten.isEmpty).andThen(opt).replace(newValue) + + replaceIfEmpty(project.license, FileValueV2Optics.licenseOption) + .andThen(replaceIfEmpty(project.copyrightAttribution, FileValueV2Optics.copyrightAttributionOption)) + } + private def triplestoreUpdate( createResourceRequestV2: CreateResourceRequestV2, ): Task[ReadResourcesSequenceV2] = @@ -166,9 +192,14 @@ final case class CreateResourceV2Handler( ZIO .fail(DuplicateValueException(s"Resource IRI: '$resourceIri' already exists.")) .whenZIO(iriService.checkIriExists(resourceIri)) + project = createResourceRequestV2.createResource.projectADM // Convert the resource to the internal ontology schema. - internalCreateResource <- ZIO.attempt(createResourceRequestV2.createResource.toOntologySchema(InternalSchema)) + internalCreateResource <- + ZIO.attempt( + replaceCopyrightAttributionAndLicenseIfMissing(project)(createResourceRequestV2.createResource) + .toOntologySchema(InternalSchema), + ) // Check link targets and list nodes that should exist. _ <- checkStandoffLinkTargets( diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala index 3af6f33f00..b7b9859a53 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraProjectService.scala @@ -105,6 +105,8 @@ final case class KnoraProjectService(knoraProjectRepo: KnoraProjectRepo, ontolog ) } yield updated + def save(project: KnoraProject): Task[KnoraProject] = knoraProjectRepo.save(project) + def getNamedGraphsForProject(project: KnoraProject): Task[List[InternalIri]] = { val projectGraph = ProjectService.projectDataNamedGraphV2(project) ontologyRepo 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 index dded0c525e..8c5a32ee9a 100644 --- 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 @@ -48,14 +48,18 @@ object ModelOps { self => case iris => Left(s"Multiple root resources found in model: ${iris.mkString(", ")}") } - def singleSubjectWithProperty(property: Property): Either[String, Resource] = + def singleSubjectWithPropertyOption(property: Property): Either[String, Option[Resource]] = val subjects = model.listSubjectsWithProperty(property).asScala.toList subjects match { - case s :: Nil => Right(s) - case Nil => Left(s"No resource found with property ${property.getURI}") - case _ => Left(s"Multiple resources found with property ${property.getURI}") + case s :: Nil => Right(Some(s)) + case Nil => Right(None) + case _ => Left(s"Multiple subjects found with property ${property.getURI}") } + def singleSubjectWithProperty(property: Property): Either[String, Resource] = + singleSubjectWithPropertyOption(property).flatMap( + _.toRight(s"No resource found with property ${property.getURI}"), + ) } def fromJsonLd(str: String): ZIO[Scope, String, Model] = from(str, Lang.JSONLD)