From 26cce4826253635ccf2050d61de557ce6331204b Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Thu, 11 Mar 2021 13:53:56 +0100 Subject: [PATCH] fix(OntologyResponderV2): Fix check when updating ontology label and comment (DSP-1390) (#1826) --- docs/03-apis/api-v2/ontology-information.md | 12 ++ .../ontologymessages/OntologyMessagesV2.scala | 15 +++ .../responders/v2/OntologyResponderV2.scala | 107 +++++++++++++++++- .../webapi/routing/v2/OntologiesRouteV2.scala | 49 ++++++++ .../v2/changeOntologyMetadata.scala.txt | 7 +- .../webapi/e2e/v2/OntologyV2R2RSpec.scala | 25 ++++ .../v2/OntologyResponderV2Spec.scala | 73 ++++++++++++ 7 files changed, 285 insertions(+), 3 deletions(-) diff --git a/docs/03-apis/api-v2/ontology-information.md b/docs/03-apis/api-v2/ontology-information.md index a46cce1f12..da52fde211 100644 --- a/docs/03-apis/api-v2/ontology-information.md +++ b/docs/03-apis/api-v2/ontology-information.md @@ -1025,6 +1025,18 @@ The request body can also contain a new label and a new comment for the ontology A successful response will be a JSON-LD document providing only the ontology's metadata. +### Deleting an Ontology's comment + +``` +HTTP DELETE to http://host/v2/ontologies/comment/ONTOLOGY_IRI?lastModificationDate=ONTOLOGY_LAST_MODIFICATION_DATE +``` + +The ontology IRI and the ontology's last modification date must be +URL-encoded. + +A successful response will be a JSON-LD document containing the ontology's +updated metadata. + ### Deleting an Ontology An ontology can be deleted only if it is not used in data. 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 229c3066f9..9b6b09c8a9 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 @@ -994,6 +994,21 @@ object ChangeOntologyMetadataRequestV2 extends KnoraJsonLDRequestReaderV2[Change } } +/** + * Deletes the comment from an ontology. A successful response will be a [[ReadOntologyMetadataV2]]. + * + * @param ontologyIri the external ontology IRI. + * @param lastModificationDate the ontology's last modification date, returned in a previous operation. + * @param apiRequestID the ID of the API request. + * @param requestingUser the user making the request. + */ +case class DeleteOntologyCommentRequestV2(ontologyIri: SmartIri, + lastModificationDate: Instant, + apiRequestID: UUID, + featureFactoryConfig: FeatureFactoryConfig, + requestingUser: UserADM) + extends OntologiesResponderRequestV2 + /** * Requests all available information about a list of ontology entities (classes and/or properties). A successful response will be an * [[EntityInfoGetResponseV2]]. 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 dd6465d557..0b52f815d4 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 @@ -136,6 +136,8 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon case createOntologyRequest: CreateOntologyRequestV2 => createOntology(createOntologyRequest) case changeOntologyMetadataRequest: ChangeOntologyMetadataRequestV2 => changeOntologyMetadata(changeOntologyMetadataRequest) + case deleteOntologyCommentRequest: DeleteOntologyCommentRequestV2 => + deleteOntologyComment(deleteOntologyCommentRequest) case createClassRequest: CreateClassRequestV2 => createClass(createClassRequest) case changeClassLabelsOrCommentsRequest: ChangeClassLabelsOrCommentsRequestV2 => changeClassLabelsOrComments(changeClassLabelsOrCommentsRequest) @@ -2140,6 +2142,7 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon ontologyIri = internalOntologyIri, newLabel = changeOntologyMetadataRequest.label, hasOldComment = ontologyHasComment, + deleteOldComment = ontologyHasComment && changeOntologyMetadataRequest.comment.nonEmpty, newComment = changeOntologyMetadataRequest.comment, lastModificationDate = changeOntologyMetadataRequest.lastModificationDate, currentTime = currentTime @@ -2159,11 +2162,20 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon changeOntologyMetadataRequest.label } + // Is there any new comment given? + comment = if (changeOntologyMetadataRequest.comment.isEmpty) { + // No. Consider the old comment for checking the update. + oldMetadata.comment + } else { + // Yes. Consider the new comment for checking the update. + changeOntologyMetadataRequest.comment + } + unescapedNewMetadata = OntologyMetadataV2( ontologyIri = internalOntologyIri, projectIri = Some(projectIri), label = label, - comment = changeOntologyMetadataRequest.comment, + comment = comment, lastModificationDate = Some(currentTime) ).unescape @@ -2206,6 +2218,99 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon } yield taskResult } + def deleteOntologyComment( + deleteOntologyCommentRequestV2: DeleteOntologyCommentRequestV2): Future[ReadOntologyMetadataV2] = { + def makeTaskFuture(internalOntologyIri: SmartIri): Future[ReadOntologyMetadataV2] = { + for { + cacheData <- getCacheData + + // Check that the user has permission to update the ontology. + projectIri <- checkPermissionsForOntologyUpdate( + internalOntologyIri = internalOntologyIri, + requestingUser = deleteOntologyCommentRequestV2.requestingUser + ) + + // Check that the ontology exists and has not been updated by another user since the client last read its metadata. + _ <- checkOntologyLastModificationDateBeforeUpdate( + internalOntologyIri = internalOntologyIri, + expectedLastModificationDate = deleteOntologyCommentRequestV2.lastModificationDate, + featureFactoryConfig = deleteOntologyCommentRequestV2.featureFactoryConfig + ) + + // get the metadata of the ontology. + oldMetadata: OntologyMetadataV2 = cacheData.ontologies(internalOntologyIri).ontologyMetadata + // Was there a comment in the ontology metadata? + ontologyHasComment: Boolean = oldMetadata.comment.nonEmpty + + // Update the metadata. + + currentTime: Instant = Instant.now + + updateSparql = org.knora.webapi.messages.twirl.queries.sparql.v2.txt + .changeOntologyMetadata( + triplestore = settings.triplestoreType, + ontologyNamedGraphIri = internalOntologyIri, + ontologyIri = internalOntologyIri, + newLabel = None, + hasOldComment = ontologyHasComment, + deleteOldComment = true, + newComment = None, + lastModificationDate = deleteOntologyCommentRequestV2.lastModificationDate, + currentTime = currentTime + ) + .toString() + + _ <- (storeManager ? SparqlUpdateRequest(updateSparql)).mapTo[SparqlUpdateResponse] + + // Check that the update was successful. + + unescapedNewMetadata = OntologyMetadataV2( + ontologyIri = internalOntologyIri, + projectIri = Some(projectIri), + label = oldMetadata.label, + comment = None, + lastModificationDate = Some(currentTime) + ).unescape + + maybeLoadedOntologyMetadata: Option[OntologyMetadataV2] <- loadOntologyMetadata( + internalOntologyIri = internalOntologyIri, + featureFactoryConfig = deleteOntologyCommentRequestV2.featureFactoryConfig + ) + + _ = maybeLoadedOntologyMetadata match { + case Some(loadedOntologyMetadata) => + if (loadedOntologyMetadata != unescapedNewMetadata) { + throw UpdateNotPerformedException() + } + + case None => throw UpdateNotPerformedException() + } + + // Update the ontology cache with the unescaped metadata. + + _ = storeCacheData( + cacheData.copy( + ontologies = cacheData.ontologies + (internalOntologyIri -> cacheData + .ontologies(internalOntologyIri) + .copy(ontologyMetadata = unescapedNewMetadata)) + )) + + } yield ReadOntologyMetadataV2(ontologies = Set(unescapedNewMetadata)) + } + + for { + _ <- checkExternalOntologyIriForUpdate(deleteOntologyCommentRequestV2.ontologyIri) + internalOntologyIri = deleteOntologyCommentRequestV2.ontologyIri.toOntologySchema(InternalSchema) + + // Do the remaining pre-update checks and the update while holding a global ontology cache lock. + taskResult <- IriLocker.runWithIriLock( + apiRequestID = deleteOntologyCommentRequestV2.apiRequestID, + iri = ONTOLOGY_CACHE_LOCK_IRI, + task = () => makeTaskFuture(internalOntologyIri = internalOntologyIri) + ) + } yield taskResult + } + /** * Creates a class in an existing ontology. * diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala index 1aa8329fe9..270cc165e3 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala @@ -63,6 +63,7 @@ class OntologiesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) replaceCardinalities(featureFactoryConfig) ~ getClasses(featureFactoryConfig) ~ deleteClass(featureFactoryConfig) ~ + deleteOntologyComment(featureFactoryConfig) ~ createProperty(featureFactoryConfig) ~ updateProperty(featureFactoryConfig) ~ getProperties(featureFactoryConfig) ~ @@ -560,6 +561,54 @@ class OntologiesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) } } + private def deleteOntologyComment(featureFactoryConfig: FeatureFactoryConfig): Route = + path(OntologiesBasePath / "comment" / Segment) { ontologyIriStr: IRI => + delete { requestContext => + { + + val ontologyIri = ontologyIriStr.toSmartIri + + if (!ontologyIri.getOntologySchema.contains(ApiV2Complex)) { + throw BadRequestException(s"Invalid class IRI for request: $ontologyIriStr") + } + + val lastModificationDateStr = requestContext.request.uri + .query() + .toMap + .getOrElse(LAST_MODIFICATION_DATE, throw BadRequestException(s"Missing parameter: $LAST_MODIFICATION_DATE")) + + val lastModificationDate = stringFormatter.xsdDateTimeStampToInstant( + lastModificationDateStr, + throw BadRequestException(s"Invalid timestamp: $lastModificationDateStr")) + + val requestMessageFuture: Future[DeleteOntologyCommentRequestV2] = for { + requestingUser <- getUserADM( + requestContext = requestContext, + featureFactoryConfig = featureFactoryConfig + ) + } yield + DeleteOntologyCommentRequestV2( + ontologyIri = ontologyIri, + lastModificationDate = lastModificationDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = featureFactoryConfig, + requestingUser = requestingUser + ) + + RouteUtilV2.runRdfRouteWithFuture( + requestMessageF = requestMessageFuture, + requestContext = requestContext, + featureFactoryConfig = featureFactoryConfig, + settings = settings, + responderManager = responderManager, + log = log, + targetSchema = ApiV2Complex, + schemaOptions = RouteUtilV2.getSchemaOptions(requestContext) + ) + } + } + } + private def createProperty(featureFactoryConfig: FeatureFactoryConfig): Route = path(OntologiesBasePath / "properties") { post { diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/changeOntologyMetadata.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/changeOntologyMetadata.scala.txt index aa2edeb384..031fa5afb1 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/changeOntologyMetadata.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/changeOntologyMetadata.scala.txt @@ -27,6 +27,8 @@ * @param ontologyNamedGraphIri the IRI of the named graph where the ontology should be stored. * @param ontologyIri the IRI of the ontology to be created. * @param newLabel the ontology's new label. + * @param hasOldComment true if the ontology has a comment. + * @param deleteOldComment true if the existing comment should be deleted. * @param newComment the ontology's new comment. * @param lastModificationDate the xsd:dateTimeStamp that was attached to the ontology when it was last modified. * @param currentTime an xsd:dateTimeStamp that will be attached to the ontology. @@ -36,6 +38,7 @@ ontologyIri: SmartIri, newLabel: Option[String], hasOldComment: Boolean, + deleteOldComment: Boolean, newComment: Option[String], lastModificationDate: Instant, currentTime: Instant) @@ -51,7 +54,7 @@ DELETE { @if(newLabel.nonEmpty) { ?ontology rdfs:label ?oldLabel . } - @if(hasOldComment && newComment.nonEmpty) { + @if(hasOldComment && (deleteOldComment || newComment.nonEmpty)) { ?ontology rdfs:comment ?oldComment . } ?ontology knora-base:lastModificationDate "@lastModificationDate"^^xsd:dateTime . @@ -81,7 +84,7 @@ WHERE { @if(newLabel.nonEmpty) { rdfs:label ?oldLabel ; } - @if(hasOldComment && newComment.nonEmpty) { + @if(hasOldComment) { rdfs:comment ?oldComment ; } knora-base:lastModificationDate "@lastModificationDate"^^xsd:dateTime . diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala index e090e6153a..fb49195090 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala @@ -561,6 +561,31 @@ class OntologyV2R2RSpec extends R2RSpec { } } + "delete the comment from 'foo'" in { + val fooIriEncoded = URLEncoder.encode(fooIri.get, "UTF-8") + val lastModificationDate = URLEncoder.encode(fooLastModDate.toString, "UTF-8") + + Delete(s"/v2/ontologies/comment/$fooIriEncoded?lastModificationDate=$lastModificationDate") ~> addCredentials( + BasicHttpCredentials(anythingUsername, password)) ~> ontologiesPath ~> check { + assert(status == StatusCodes.OK, response.toString) + val responseJsonDoc = responseToJsonLDDocument(response) + val metadata = responseJsonDoc.body + val ontologyIri = metadata.value("@id").asInstanceOf[JsonLDString].value + assert(ontologyIri == fooIri.get) + assert(metadata.value(OntologyConstants.Rdfs.Label) == JsonLDString("The modified foo ontology")) + assert(!metadata.value.contains(OntologyConstants.Rdfs.Comment)) + + val lastModDate = metadata.requireDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.LastModificationDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ) + + assert(lastModDate.isAfter(fooLastModDate)) + fooLastModDate = lastModDate + } + } + "delete the 'foo' ontology" in { val fooIriEncoded = URLEncoder.encode(fooIri.get, "UTF-8") val lastModificationDate = URLEncoder.encode(fooLastModDate.toString, "UTF-8") diff --git a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala index 0efc94cb19..f2d84c1847 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala @@ -174,6 +174,79 @@ class OntologyResponderV2Spec extends CoreSpec() with ImplicitSender { fooLastModDate = newFooLastModDate } + "change both the label and the comment of the 'foo' ontology" in { + val aLabel = "a changed label" + val aComment = "a changed comment" + + responderManager ! ChangeOntologyMetadataRequestV2( + ontologyIri = fooIri.get.toSmartIri.toOntologySchema(ApiV2Complex), + label = Some(aLabel), + comment = Some(aComment), + lastModificationDate = fooLastModDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = imagesUser + ) + + val response = expectMsgType[ReadOntologyMetadataV2](timeout) + assert(response.ontologies.size == 1) + val metadata = response.ontologies.head + assert(metadata.ontologyIri == fooIri.get.toSmartIri) + assert(metadata.label.contains(aLabel)) + assert(metadata.comment.contains(aComment)) + val newFooLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date")) + assert(newFooLastModDate.isAfter(fooLastModDate)) + fooLastModDate = newFooLastModDate + } + + "change the label of 'foo' again" in { + val newLabel = "a label changed again" + + responderManager ! ChangeOntologyMetadataRequestV2( + ontologyIri = fooIri.get.toSmartIri.toOntologySchema(ApiV2Complex), + label = Some(newLabel), + lastModificationDate = fooLastModDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = imagesUser + ) + + val response = expectMsgType[ReadOntologyMetadataV2](timeout) + assert(response.ontologies.size == 1) + val metadata = response.ontologies.head + assert(metadata.ontologyIri == fooIri.get.toSmartIri) + assert(metadata.label.contains(newLabel)) + assert(metadata.comment.contains("a changed comment")) + val newFooLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date")) + assert(newFooLastModDate.isAfter(fooLastModDate)) + fooLastModDate = newFooLastModDate + } + + "delete the comment from 'foo'" in { + val newLabel = "a label changed again" + + responderManager ! DeleteOntologyCommentRequestV2( + ontologyIri = fooIri.get.toSmartIri.toOntologySchema(ApiV2Complex), + lastModificationDate = fooLastModDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = imagesUser + ) + + val response = expectMsgType[ReadOntologyMetadataV2](timeout) + assert(response.ontologies.size == 1) + val metadata = response.ontologies.head + assert(metadata.ontologyIri == fooIri.get.toSmartIri) + assert(metadata.label.contains("a label changed again")) + assert(metadata.comment.isEmpty) + val newFooLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date")) + assert(newFooLastModDate.isAfter(fooLastModDate)) + fooLastModDate = newFooLastModDate + } + "create an empty ontology called 'bar' with a comment" in { responderManager ! CreateOntologyRequestV2( ontologyName = "bar",