diff --git a/docs/03-apis/api-v2/editing-resources.md b/docs/03-apis/api-v2/editing-resources.md index a1662ac2a6..20e35a38a8 100644 --- a/docs/03-apis/api-v2/editing-resources.md +++ b/docs/03-apis/api-v2/editing-resources.md @@ -330,6 +330,11 @@ The request body is a JSON-LD object containing the following information about The optional property `knora-api:deleteComment` specifies a comment to be attached to the resource, explaining why it has been marked as deleted. +The optional property `knora-api:deleteDate` +(an [xsd:dateTimeStamp](https://www.w3.org/TR/xmlschema11-2/#dateTimeStamp)) +indicates when the resource was marked as deleted; if not given, the current +time is used. + The response is a JSON-LD document containing the predicate `knora-api:result` with a confirmation message. diff --git a/docs/03-apis/api-v2/editing-values.md b/docs/03-apis/api-v2/editing-values.md index 61b1882c59..e7710cca48 100644 --- a/docs/03-apis/api-v2/editing-values.md +++ b/docs/03-apis/api-v2/editing-values.md @@ -83,8 +83,9 @@ Permissions for the new value can be given by adding `knora-api:hasPermissions`. } } ``` + Each value can have an optional custom IRI (of [Knora IRI](knora-iris.md#iris-for-data) form) specified by the `@id` attribute, a custom creation date specified by adding -`knora-api:creationDate` (an [xsd:dateTimeStamp](https://www.w3.org/TR/xmlschema11-2/#dateTimeStamp)), or a custom UUID +`knora-api:valueCreationDate` (an [xsd:dateTimeStamp](https://www.w3.org/TR/xmlschema11-2/#dateTimeStamp)), or a custom UUID given by `knora-api:valueHasUUID`. Each custom UUID must be [base64url-encoded](rfc:4648#section-5), without padding. For example: @@ -97,7 +98,7 @@ For example: "@type" : "knora-api:IntValue", "knora-api:intValueAsInt" : 21, "knora-api:valueHasUUID" : "IN4R19yYR0ygi3K2VEHpUQ", - "knora-api:creationDate" : { + "knora-api:valueCreationDate" : { "@type" : "xsd:dateTimeStamp", "@value" : "2020-06-04T12:58:54.502951Z" } @@ -108,6 +109,7 @@ For example: "xsd" : "http://www.w3.org/2001/XMLSchema#" } ``` + The format of the object of `knora-api:hasPermissions` is described in [Permissions](../../02-knora-ontologies/knora-base.md#permissions). @@ -386,6 +388,9 @@ To update only the permissions on a value, submit it with the new permissions an To update a link, the user must have **modify permission** on the containing resource as well as on the value. +To update a value and give it a custom timestamp, add +`knora-api:valueCreationDate` (an [xsd:dateTimeStamp](https://www.w3.org/TR/xmlschema11-2/#dateTimeStamp)). + The response is a JSON-LD document containing only `@id` and `@type`, returning the IRI and type of the new value version. @@ -431,7 +436,10 @@ the resource to the value, and the value's ID and type. For example: ``` The optional property `knora-api:deleteComment` specifies a comment to be attached to the -value, explaining why it has been marked as deleted. +value, explaining why it has been marked as deleted + +The optional property `knora-api:deleteDate` (an [xsd:dateTimeStamp](https://www.w3.org/TR/xmlschema11-2/#dateTimeStamp)) +specifies a custom timestamp indicating when the value was deleted. If not specified, the current time is used. The response is a JSON-LD document containing the predicate `knora-api:result` with a confirmation message. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/JsonLDUtil.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/JsonLDUtil.scala index 5c5eda5054..4c93020533 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/JsonLDUtil.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/JsonLDUtil.scala @@ -461,16 +461,13 @@ case class JsonLDObject(value: Map[String, JsonLDValue]) extends JsonLDValue { } /** - * Validates the optional `uuid` of a JSON-LD object as a value uuid. + * Validates an optional Base64-encoded UUID in a JSON-LD object. * * @return an optional validated decoded UUID. */ - def maybeUUID: Option[UUID] = { + def maybeUUID(key: String): Option[UUID] = { implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance - - val maybeUUID: Option[UUID] = maybeStringWithValidation(OntologyConstants.KnoraApiV2Complex.ValueHasUUID, stringFormatter.validateBase64EncodedUuid) - - maybeUUID + maybeStringWithValidation(key, stringFormatter.validateBase64EncodedUuid) } /** @@ -678,6 +675,11 @@ case class JsonLDDocument(body: JsonLDObject, context: JsonLDObject = JsonLDObje */ def requireResourcePropertyValue: (SmartIri, JsonLDObject) = body.requireResourcePropertyApiV2ComplexValue + /** + * A convenience function that calls `body.maybeUUID`. + */ + def maybeUUID(key: String): Option[UUID] = body.maybeUUID(key: String) + /** * Converts this JSON-LD object to its compacted Java representation. */ 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 23726a9ea4..fbfd77ecbd 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 @@ -584,13 +584,12 @@ object CreateResourceRequestV2 extends KnoraJsonLDRequestReaderV2[CreateResource OntologyConstants.KnoraApiV2Complex.CreationDate ) - valueFutures: Map[SmartIri, Seq[Future[CreateValueInNewResourceV2]]] = propertyIriStrs.map { + propertyValueFuturesMap: Map[SmartIri, Seq[Future[CreateValueInNewResourceV2]]] = propertyIriStrs.map { propertyIriStr => val propertyIri: SmartIri = propertyIriStr.toSmartIriWithErr(throw BadRequestException(s"Invalid property IRI: <$propertyIriStr>")) - val valuesArray: JsonLDArray = jsonLDDocument.requireArray(propertyIriStr) - val propertyValues = valuesArray.value.map { + val valueFuturesSeq: Seq[Future[CreateValueInNewResourceV2]] = valuesArray.value.map { valueJsonLD => val valueJsonLDObject = valueJsonLD match { case jsonLDObject: JsonLDObject => jsonLDObject @@ -606,15 +605,22 @@ object CreateResourceRequestV2 extends KnoraJsonLDRequestReaderV2[CreateResource settings = settings, log = log ) + maybeCustomValueIri: Option[SmartIri] = valueJsonLDObject.maybeIDAsKnoraDataIri - maybeCustomValueUUID: Option[UUID] = valueJsonLDObject.maybeUUID + maybeCustomValueUUID: Option[UUID] = valueJsonLDObject.maybeUUID(OntologyConstants.KnoraApiV2Complex.ValueHasUUID) - // Get the values's creation date. + // Get the value's creation date. + // TODO: creationDate for values is a bug, and will not be supported in future. Use valueCreationDate instead. maybeCustomValueCreationDate: Option[Instant] = valueJsonLDObject.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ).orElse(valueJsonLDObject.maybeDatatypeValueInObject( key = OntologyConstants.KnoraApiV2Complex.CreationDate, expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, validationFun = stringFormatter.xsdDateTimeStampToInstant - ) + )) + maybePermissions: Option[String] = valueJsonLDObject.maybeStringWithValidation(OntologyConstants.KnoraApiV2Complex.HasPermissions, stringFormatter.toSparqlEncodedString) } yield CreateValueInNewResourceV2( valueContent = valueContent, @@ -625,23 +631,22 @@ object CreateResourceRequestV2 extends KnoraJsonLDRequestReaderV2[CreateResource ) } - propertyIri -> propertyValues + propertyIri -> valueFuturesSeq }.toMap - values: Map[SmartIri, Seq[CreateValueInNewResourceV2]] <- ActorUtil.sequenceSeqFuturesInMap(valueFutures) + propertyValuesMap: Map[SmartIri, Seq[CreateValueInNewResourceV2]] <- ActorUtil.sequenceSeqFuturesInMap(propertyValueFuturesMap) // Get information about the project that the resource should be created in. projectInfoResponse: ProjectGetResponseADM <- (responderManager ? ProjectGetRequestADM( ProjectIdentifierADM(maybeIri = Some(projectIri.toString)), requestingUser = requestingUser )).mapTo[ProjectGetResponseADM] - } yield CreateResourceRequestV2( createResource = CreateResourceV2( resourceIri = maybeCustomResourceIri, resourceClassIri = resourceClassIri, label = label, - values = values, + values = propertyValuesMap, projectADM = projectInfoResponse.project, permissions = permissions, creationDate = creationDate @@ -751,12 +756,15 @@ object UpdateResourceMetadataRequestV2 extends KnoraJsonLDRequestReaderV2[Update * @param resourceIri the IRI of the resource. * @param resourceClassIri the IRI of the resource class. * @param maybeDeleteComment a comment explaining why the resource is being marked as deleted. + * @param maybeDeleteDate a timestamp indicating when the resource was marked as deleted. If not supplied, + * the current time will be used. * @param maybeLastModificationDate the resource's last modification date, if any. * @param erase if `true`, the resource will be erased from the triplestore, otherwise it will be marked as deleted. */ case class DeleteOrEraseResourceRequestV2(resourceIri: IRI, resourceClassIri: SmartIri, maybeDeleteComment: Option[String] = None, + maybeDeleteDate: Option[Instant] = None, maybeLastModificationDate: Option[Instant] = None, erase: Boolean = false, requestingUser: UserADM, @@ -812,10 +820,17 @@ object DeleteOrEraseResourceRequestV2 extends KnoraJsonLDRequestReaderV2[DeleteO val maybeDeleteComment: Option[String] = jsonLDDocument.maybeStringWithValidation(OntologyConstants.KnoraApiV2Complex.DeleteComment, stringFormatter.toSparqlEncodedString) + val maybeDeleteDate: Option[Instant] = jsonLDDocument.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.DeleteDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ) + DeleteOrEraseResourceRequestV2( resourceIri = resourceIri.toString, resourceClassIri = resourceClassIri, maybeDeleteComment = maybeDeleteComment, + maybeDeleteDate = maybeDeleteDate, maybeLastModificationDate = maybeLastModificationDate, requestingUser = requestingUser, apiRequestID = apiRequestID 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 81d38680fd..c9d783ff7d 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 @@ -120,23 +120,29 @@ object CreateValueRequestV2 extends KnoraJsonLDRequestReaderV2[CreateValueReques maybeCustomValueIri: Option[SmartIri] = jsonLDObject.maybeIDAsKnoraDataIri // Get the custom value UUID if provided. - maybeCustomUUID: Option[UUID] = jsonLDObject.maybeUUID + maybeCustomUUID: Option[UUID] = jsonLDObject.maybeUUID(OntologyConstants.KnoraApiV2Complex.ValueHasUUID) // Get the value's creation date. + // TODO: creationDate for values is a bug, and will not be supported in future. Use valueCreationDate instead. maybeCreationDate: Option[Instant] = jsonLDObject.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ).orElse(jsonLDObject.maybeDatatypeValueInObject( key = OntologyConstants.KnoraApiV2Complex.CreationDate, expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, validationFun = stringFormatter.xsdDateTimeStampToInstant - ) + )) + maybePermissions: Option[String] = jsonLDObject.maybeStringWithValidation(OntologyConstants.KnoraApiV2Complex.HasPermissions, stringFormatter.toSparqlEncodedString) } yield CreateValueV2( resourceIri = resourceIri.toString, resourceClassIri = resourceClassIri, propertyIri = propertyIri, valueContent = valueContent, - customValueIri = maybeCustomValueIri, - customValueUUID = maybeCustomUUID, - customValueCreationDate = maybeCreationDate, + valueIri = maybeCustomValueIri, + valueUUID = maybeCustomUUID, + valueCreationDate = maybeCreationDate, permissions = maybePermissions ) } @@ -244,6 +250,13 @@ object UpdateValueRequestV2 extends KnoraJsonLDRequestReaderV2[UpdateValueReques case (propertyIri: SmartIri, jsonLDObject: JsonLDObject) => val valueIri: IRI = jsonLDObject.requireIDAsKnoraDataIri.toString + // Get the custom value creation date, if provided. + val maybeValueCreationDate: Option[Instant] = jsonLDObject.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ) + // Does the value object just contain knora-api:hasPermissions? val valuePredicatesMinusIDAndType: Set[IRI] = jsonLDObject.value.keySet - JsonLDConstants.ID - JsonLDConstants.TYPE @@ -261,7 +274,8 @@ object UpdateValueRequestV2 extends KnoraJsonLDRequestReaderV2[UpdateValueReques propertyIri = propertyIri, valueIri = valueIri, valueType = valueType, - permissions = permissions + permissions = permissions, + valueCreationDate = maybeValueCreationDate ) ) } else { @@ -284,9 +298,9 @@ object UpdateValueRequestV2 extends KnoraJsonLDRequestReaderV2[UpdateValueReques propertyIri = propertyIri, valueIri = valueIri, valueContent = valueContent, - permissions = maybePermissions + permissions = maybePermissions, + valueCreationDate = maybeValueCreationDate ) - } } } yield UpdateValueRequestV2( @@ -342,6 +356,8 @@ case class UpdateValueResponseV2(valueIri: IRI, * @param valueIri the IRI of the value to be marked as deleted. * @param valueTypeIri the IRI of the value class. * @param deleteComment an optional comment explaining why the value is being marked as deleted. + * @param deleteDate an optional timestamp indicating when the value was deleted. If not supplied, + * the current time will be used. * @param requestingUser the user making the request. * @param apiRequestID the API request ID. */ @@ -351,6 +367,7 @@ case class DeleteValueRequestV2(resourceIri: IRI, valueIri: IRI, valueTypeIri: SmartIri, deleteComment: Option[String] = None, + deleteDate: Option[Instant] = None, requestingUser: UserADM, apiRequestID: UUID) extends ValuesResponderRequestV2 @@ -413,6 +430,12 @@ object DeleteValueRequestV2 extends KnoraJsonLDRequestReaderV2[DeleteValueReques val deleteComment: Option[String] = jsonLDObject.maybeStringWithValidation(OntologyConstants.KnoraApiV2Complex.DeleteComment, stringFormatter.toSparqlEncodedString) + val deleteDate: Option[Instant] = jsonLDObject.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.DeleteDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ) + DeleteValueRequestV2( resourceIri = resourceIri.toString, resourceClassIri = resourceClassIri, @@ -420,6 +443,7 @@ object DeleteValueRequestV2 extends KnoraJsonLDRequestReaderV2[DeleteValueReques valueIri = valueIri.toString, valueTypeIri = valueTypeIri, deleteComment = deleteComment, + deleteDate = deleteDate, requestingUser = requestingUser, apiRequestID = apiRequestID ) @@ -785,22 +809,23 @@ case class ReadOtherValueV2(valueIri: IRI, /** * Represents a Knora value to be created in an existing resource. * - * @param resourceIri the resource the new value should be attached to. - * @param resourceClassIri the resource class that the client believes the resource belongs to. - * @param propertyIri the property of the new value. If the client wants to create a link, this must be a link value property. - * @param valueContent the content of the new value. If the client wants to create a link, this must be a [[LinkValueContentV2]]. - * @param customValueIri the optional custom IRI supplied for the value. - * @param customValueUUID the optional custom UUID supplied for the value. - * @param customValueCreationDate the optional custom creation date supplied for the value. - * @param permissions the permissions to be given to the new value. If not provided, these will be taken from defaults. + * @param resourceIri the resource the new value should be attached to. + * @param resourceClassIri the resource class that the client believes the resource belongs to. + * @param propertyIri the property of the new value. If the client wants to create a link, this must be a link value property. + * @param valueContent the content of the new value. If the client wants to create a link, this must be a [[LinkValueContentV2]]. + * @param valueIri the optional custom IRI supplied for the value. + * @param valueUUID the optional custom UUID supplied for the value. + * @param valueCreationDate the optional custom creation date supplied for the value. If not supplied, + * the current time will be used. + * @param permissions the permissions to be given to the new value. If not provided, these will be taken from defaults. */ case class CreateValueV2(resourceIri: IRI, resourceClassIri: SmartIri, propertyIri: SmartIri, valueContent: ValueContentV2, - customValueIri: Option[SmartIri] = None, - customValueUUID: Option[UUID] = None, - customValueCreationDate: Option[Instant] = None, + valueIri: Option[SmartIri] = None, + valueUUID: Option[UUID] = None, + valueCreationDate: Option[Instant] = None, permissions: Option[String] = None) extends IOValueV2 @@ -827,43 +852,54 @@ trait UpdateValueV2 { * The value IRI. */ val valueIri: IRI + + /** + * A custom value creation date. + */ + val valueCreationDate: Option[Instant] } /** * A new version of a value of a Knora property to be created. * - * @param resourceIri the resource that the current value version is attached to. - * @param resourceClassIri the resource class that the client believes the resource belongs to. - * @param propertyIri the property that the client believes points to the value. If the value is a link value, - * this must be a link value property. - * @param valueIri the IRI of the value to be updated. - * @param valueContent the content of the new version of the value. - * @param permissions the permissions to be attached to the new value version. + * @param resourceIri the resource that the current value version is attached to. + * @param resourceClassIri the resource class that the client believes the resource belongs to. + * @param propertyIri the property that the client believes points to the value. If the value is a link value, + * this must be a link value property. + * @param valueIri the IRI of the value to be updated. + * @param valueContent the content of the new version of the value. + * @param permissions the permissions to be attached to the new value version. + * @param valueCreationDate an optional custom creation date to be attached to the new value version. If not supplied, + * the current time will be used. */ case class UpdateValueContentV2(resourceIri: IRI, resourceClassIri: SmartIri, propertyIri: SmartIri, valueIri: IRI, valueContent: ValueContentV2, - permissions: Option[String] = None) extends IOValueV2 with UpdateValueV2 + permissions: Option[String] = None, + valueCreationDate: Option[Instant] = None) extends IOValueV2 with UpdateValueV2 /** * New permissions for a value. * - * @param resourceIri the resource that the current value version is attached to. - * @param resourceClassIri the resource class that the client believes the resource belongs to. - * @param propertyIri the property that the client believes points to the value. If the value is a link value, - * this must be a link value property. - * @param valueIri the IRI of the value to be updated. - * @param valueType the IRI of the value type. - * @param permissions the permissions to be attached to the new value version. + * @param resourceIri the resource that the current value version is attached to. + * @param resourceClassIri the resource class that the client believes the resource belongs to. + * @param propertyIri the property that the client believes points to the value. If the value is a link value, + * this must be a link value property. + * @param valueIri the IRI of the value to be updated. + * @param valueType the IRI of the value type. + * @param permissions the permissions to be attached to the new value version. + * @param valueCreationDate an optional custom creation date to be attached to the new value version. If not supplied, + * the current time will be used. */ case class UpdateValuePermissionsV2(resourceIri: IRI, resourceClassIri: SmartIri, propertyIri: SmartIri, valueIri: IRI, valueType: SmartIri, - permissions: String) extends UpdateValueV2 + permissions: String, + valueCreationDate: Option[Instant] = None) extends UpdateValueV2 /** * The IRI and content of a new value or value version whose existence in the triplestore needs to be verified. 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 80d03e88fc..93a7123350 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 @@ -78,7 +78,7 @@ class ResourcesResponderV2(responderData: ResponderData) extends ResponderWithSt case ResourceTEIGetRequestV2(resIri, textProperty, mappingIri, gravsearchTemplateIri, headerXSLTIri, requestingUser) => getResourceAsTeiV2(resIri, textProperty, mappingIri, gravsearchTemplateIri, headerXSLTIri, requestingUser) case createResourceRequestV2: CreateResourceRequestV2 => createResourceV2(createResourceRequestV2) case updateResourceMetadataRequestV2: UpdateResourceMetadataRequestV2 => updateResourceMetadataV2(updateResourceMetadataRequestV2) - case deleteResourceRequestV2: DeleteOrEraseResourceRequestV2 => deleteOrEraseResourceV2(deleteResourceRequestV2) + case deleteOrEraseResourceRequestV2: DeleteOrEraseResourceRequestV2 => deleteOrEraseResourceV2(deleteOrEraseResourceRequestV2) case graphDataGetRequest: GraphDataGetRequestV2 => getGraphDataResponseV2(graphDataGetRequest) case resourceHistoryRequest: ResourceVersionHistoryGetRequestV2 => getResourceHistoryV2(resourceHistoryRequest) case other => handleUnexpectedMessage(other, log, this.getClass.getName) @@ -419,6 +419,11 @@ class ResourcesResponderV2(responderData: ResponderData) extends ResponderWithSt throw EditConflictException(s"Resource <${resource.resourceIri}> has been modified since you last read it") } + // If a custom delete date was provided, make sure it's later than the resource's most recent timestamp. + _ = if (deleteResourceV2.maybeDeleteDate.exists(!_.isAfter(resource.lastModificationDate.getOrElse(resource.creationDate)))) { + throw BadRequestException(s"A custom delete date must be later than the date when the resource was created or last modified") + } + // Check that the user has permission to mark the resource as deleted. _ = ResourceUtilV2.checkResourcePermission( resourceInfo = resource, @@ -435,7 +440,7 @@ class ResourcesResponderV2(responderData: ResponderData) extends ResponderWithSt dataNamedGraph = dataNamedGraph, resourceIri = deleteResourceV2.resourceIri, maybeDeleteComment = deleteResourceV2.maybeDeleteComment, - currentTime = Instant.now, + currentTime = deleteResourceV2.maybeDeleteDate.getOrElse(Instant.now), requestingUser = deleteResourceV2.requestingUser.id ).toString() 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 866f783c67..5781705782 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 @@ -251,9 +251,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo = resourceInfo, propertyIri = adjustedInternalPropertyIri, value = submittedInternalValueContent, - customValueIri = createValueRequest.createValue.customValueIri, - customValueUUID = createValueRequest.createValue.customValueUUID, - customValueCreationDate = createValueRequest.createValue.customValueCreationDate, + valueIri = createValueRequest.createValue.valueIri, + valueUUID = createValueRequest.createValue.valueUUID, + valueCreationDate = createValueRequest.createValue.valueCreationDate, valueCreator = createValueRequest.requestingUser.id, valuePermissions = newValuePermissionLiteral, requestingUser = createValueRequest.requestingUser @@ -315,19 +315,19 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde * Creates a new value (either an ordinary value or a link), using an existing transaction, assuming that * pre-update checks have already been done. * - * @param dataNamedGraph the named graph in which the value is to be created. - * @param projectIri the IRI of the project in which to create the value. - * @param resourceInfo information about the the resource in which to create the value. - * @param propertyIri the IRI of the property that will point from the resource to the value, or, if - * the value is a link value, the IRI of the link property. - * @param value the value to create. - * @param customValueIri the optional custom IRI supplied for the value. - * @param customValueUUID the optional custom UUID supplied for the value. - * @param customValueCreationDate the optional custom creation date supplied for the value. - * @param valueCreator the IRI of the new value's owner. - * @param valuePermissions the literal that should be used as the object of the new value's - * `knora-base:hasPermissions` predicate. - * @param requestingUser the user making the request. + * @param dataNamedGraph the named graph in which the value is to be created. + * @param projectIri the IRI of the project in which to create the value. + * @param resourceInfo information about the the resource in which to create the value. + * @param propertyIri the IRI of the property that will point from the resource to the value, or, if + * the value is a link value, the IRI of the link property. + * @param value the value to create. + * @param valueIri the optional custom IRI supplied for the value. + * @param valueUUID the optional custom UUID supplied for the value. + * @param valueCreationDate the optional custom creation date supplied for the value. + * @param valueCreator the IRI of the new value's owner. + * @param valuePermissions the literal that should be used as the object of the new value's + * `knora-base:hasPermissions` predicate. + * @param requestingUser the user making the request. * @return an [[UnverifiedValueV2]]. */ private def createValueV2AfterChecks(dataNamedGraph: IRI, @@ -335,13 +335,12 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo: ReadResourceV2, propertyIri: SmartIri, value: ValueContentV2, - customValueIri: Option[SmartIri], - customValueUUID: Option[UUID], - customValueCreationDate: Option[Instant], + valueIri: Option[SmartIri], + valueUUID: Option[UUID], + valueCreationDate: Option[Instant], valueCreator: IRI, valuePermissions: String, requestingUser: UserADM): Future[UnverifiedValueV2] = { - value match { case linkValueContent: LinkValueContentV2 => createLinkValueV2AfterChecks( @@ -349,9 +348,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo = resourceInfo, linkPropertyIri = propertyIri, linkValueContent = linkValueContent, - maybeValueIri = customValueIri, - maybeValueUUID = customValueUUID, - maybeCreationDate = customValueCreationDate, + maybeValueIri = valueIri, + maybeValueUUID = valueUUID, + maybeCreationDate = valueCreationDate, valueCreator = valueCreator, valuePermissions = valuePermissions, requestingUser = requestingUser @@ -363,9 +362,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo = resourceInfo, propertyIri = propertyIri, value = ordinaryValueContent, - maybeValueIri = customValueIri, - maybeValueUUID = customValueUUID, - maybeValueCreationDate = customValueCreationDate, + maybeValueIri = valueIri, + maybeValueUUID = valueUUID, + maybeValueCreationDate = valueCreationDate, valueCreator = valueCreator, valuePermissions = valuePermissions, requestingUser = requestingUser @@ -853,6 +852,11 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde _ = if (currentValue.valueContent.valueType != submittedExternalValueType.toOntologySchema(InternalSchema)) { throw BadRequestException(s"Value <$valueIri> has type <${currentValue.valueContent.valueType.toOntologySchema(ApiV2Complex)}>, but the submitted type was <$submittedExternalValueType>") } + + // If a custom value creation date was submitted, make sure it's later than the date of the current version. + _ = if (updateValueRequest.updateValue.valueCreationDate.exists(!_.isAfter(currentValue.valueCreationDate))) { + throw BadRequestException("A custom value creation date must be later than the date of the current version") + } } yield ResourcePropertyValue( resource = resourceInfo, submittedInternalPropertyIri = submittedInternalPropertyIri, @@ -907,7 +911,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde dataNamedGraph: IRI = stringFormatter.projectDataNamedGraphV2(resourceInfo.projectADM) newValueIri: IRI = stringFormatter.makeRandomValueIri(resourceInfo.resourceIri) - currentTime: Instant = Instant.now + currentTime: Instant = updateValuePermissionsV2.valueCreationDate.getOrElse(Instant.now) sparqlUpdate = org.knora.webapi.messages.twirl.queries.sparql.v2.txt.changeValuePermissions( dataNamedGraph = dataNamedGraph, @@ -1069,6 +1073,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde newLinkValue = newLinkValue, valueCreator = updateValueRequest.requestingUser.id, valuePermissions = newValueVersionPermissionLiteral, + valueCreationDate = updateValueContentV2.valueCreationDate, requestingUser = updateValueRequest.requestingUser ) @@ -1081,6 +1086,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde newValueVersion = submittedInternalValueContent, valueCreator = updateValueRequest.requestingUser.id, valuePermissions = newValueVersionPermissionLiteral, + valueCreationDate = updateValueContentV2.valueCreationDate, requestingUser = updateValueRequest.requestingUser ) } @@ -1136,14 +1142,15 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde /** * Changes an ordinary value (i.e. not a link), assuming that pre-update checks have already been done. * - * @param dataNamedGraph the IRI of the named graph to be updated. - * @param resourceInfo information about the resource containing the value. - * @param propertyIri the IRI of the property that points to the value. - * @param currentValue a [[ReadValueV2]] representing the existing value version. - * @param newValueVersion a [[ValueContentV2]] representing the new value version, in the internal schema. - * @param valueCreator the IRI of the new value's owner. - * @param valuePermissions the literal that should be used as the object of the new value's `knora-base:hasPermissions` predicate. - * @param requestingUser the user making the request. + * @param dataNamedGraph the IRI of the named graph to be updated. + * @param resourceInfo information about the resource containing the value. + * @param propertyIri the IRI of the property that points to the value. + * @param currentValue a [[ReadValueV2]] representing the existing value version. + * @param newValueVersion a [[ValueContentV2]] representing the new value version, in the internal schema. + * @param valueCreator the IRI of the new value's owner. + * @param valuePermissions the literal that should be used as the object of the new value's `knora-base:hasPermissions` predicate. + * @param valueCreationDate a custom value creation date. + * @param requestingUser the user making the request. * @return an [[UnverifiedValueV2]]. */ private def updateOrdinaryValueV2AfterChecks(dataNamedGraph: IRI, @@ -1153,6 +1160,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde newValueVersion: ValueContentV2, valueCreator: IRI, valuePermissions: String, + valueCreationDate: Option[Instant], requestingUser: UserADM): Future[UnverifiedValueV2] = { for { newValueIri: IRI <- makeUnusedValueIri(resourceInfo.resourceIri) @@ -1203,8 +1211,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde case _ => FastFuture.successful(Vector.empty[SparqlTemplateLinkUpdate]) } - // Make a timestamp to indicate when the value was updated. - currentTime: Instant = Instant.now + // If no custom value creation date was provided, make a timestamp to indicate when the value + // was updated. + currentTime: Instant = valueCreationDate.getOrElse(Instant.now) // Generate a SPARQL update. sparqlUpdate = org.knora.webapi.messages.twirl.queries.sparql.v2.txt.addValueVersion( @@ -1246,14 +1255,15 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde /** * Changes a link, assuming that pre-update checks have already been done. * - * @param dataNamedGraph the IRI of the named graph to be updated. - * @param resourceInfo information about the resource containing the link. - * @param linkPropertyIri the IRI of the link property. - * @param currentLinkValue a [[ReadLinkValueV2]] representing the `knora-base:LinkValue` for the existing link. - * @param newLinkValue a [[LinkValueContentV2]] indicating the new target resource. - * @param valueCreator the IRI of the new link value's owner. - * @param valuePermissions the literal that should be used as the object of the new link value's `knora-base:hasPermissions` predicate. - * @param requestingUser the user making the request. + * @param dataNamedGraph the IRI of the named graph to be updated. + * @param resourceInfo information about the resource containing the link. + * @param linkPropertyIri the IRI of the link property. + * @param currentLinkValue a [[ReadLinkValueV2]] representing the `knora-base:LinkValue` for the existing link. + * @param newLinkValue a [[LinkValueContentV2]] indicating the new target resource. + * @param valueCreator the IRI of the new link value's owner. + * @param valuePermissions the literal that should be used as the object of the new link value's `knora-base:hasPermissions` predicate. + * @param valueCreationDate a custom value creation date. + * @param requestingUser the user making the request. * @return an [[UnverifiedValueV2]]. */ private def updateLinkValueV2AfterChecks(dataNamedGraph: IRI, @@ -1263,6 +1273,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde newLinkValue: LinkValueContentV2, valueCreator: IRI, valuePermissions: String, + valueCreationDate: Option[Instant], requestingUser: UserADM): Future[UnverifiedValueV2] = { // Are we changing the link target? @@ -1288,8 +1299,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde requestingUser = requestingUser ) - // Make a timestamp to indicate when the link value was updated. - currentTime: Instant = Instant.now + // If no custom value creation date was provided, make a timestamp to indicate when the link value + // was updated. + currentTime: Instant = valueCreationDate.getOrElse(Instant.now) // Make a new UUID for the new link value. newLinkValueUUID = UUID.randomUUID @@ -1465,6 +1477,11 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde throw OntologyConstraintException(s"Resource class <${resourceInfo.resourceClassIri.toOntologySchema(ApiV2Complex)}> has a cardinality of ${cardinalityInfo.cardinality} on property <${deleteValueRequest.propertyIri}>, and this does not allow a value to be deleted for that property from resource <${deleteValueRequest.resourceIri}>") } + // If a custom delete date was submitted, make sure it's later than the date of the current version. + _ = if (deleteValueRequest.deleteDate.exists(!_.isAfter(currentValue.valueCreationDate))) { + throw BadRequestException("A custom delete date must be later than the value's creation date") + } + // Get information about the project that the resource is in, so we know which named graph to do the update in. dataNamedGraph: IRI = stringFormatter.projectDataNamedGraphV2(resourceInfo.projectADM) @@ -1475,6 +1492,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo = resourceInfo, propertyIri = adjustedInternalPropertyIri, deleteComment = deleteValueRequest.deleteComment, + deleteDate = deleteValueRequest.deleteDate, currentValue = currentValue, requestingUser = deleteValueRequest.requestingUser ) @@ -1523,6 +1541,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde * @param propertyIri the IRI of the property that points from the resource to the value. * @param currentValue the value to be deleted. * @param deleteComment an optional comment explaining why the value is being deleted. + * @param deleteDate an optional timestamp indicating when the value was deleted. * @param requestingUser the user making the request. * @return the IRI of the value that was marked as deleted. */ @@ -1530,6 +1549,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde resourceInfo: ReadResourceV2, propertyIri: SmartIri, deleteComment: Option[String], + deleteDate: Option[Instant], currentValue: ReadValueV2, requestingUser: UserADM): Future[IRI] = { currentValue.valueContent match { @@ -1540,6 +1560,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde propertyIri = propertyIri, currentValue = currentValue, deleteComment = deleteComment, + deleteDate = deleteDate, requestingUser = requestingUser ) @@ -1550,6 +1571,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde propertyIri = propertyIri, currentValue = currentValue, deleteComment = deleteComment, + deleteDate = deleteDate, requestingUser = requestingUser ) } @@ -1563,6 +1585,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde * @param propertyIri the IRI of the property that points from the resource to the value. * @param currentValue the value to be deleted. * @param deleteComment an optional comment explaining why the value is being deleted. + * @param deleteDate an optional timestamp indicating when the value was deleted. * @param requestingUser the user making the request. * @return the IRI of the value that was marked as deleted. */ @@ -1571,6 +1594,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde propertyIri: SmartIri, currentValue: ReadValueV2, deleteComment: Option[String], + deleteDate: Option[Instant], requestingUser: UserADM): Future[IRI] = { // Make a new version of of the LinkValue with a reference count of 0, and mark the new // version as deleted. Give the new version the same permissions as the previous version. @@ -1580,8 +1604,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde case _ => throw AssertionException("Unreachable code") } - // Make a timestamp to indicate when the link value was updated. - val currentTime: Instant = Instant.now + // If no custom delete date was provided, make a timestamp to indicate when the link value was + // marked as deleted. + val currentTime: Instant = deleteDate.getOrElse(Instant.now) for { // Delete the existing link and decrement its LinkValue's reference count. @@ -1616,6 +1641,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde * @param propertyIri the IRI of the property that points from the resource to the value. * @param currentValue the value to be deleted. * @param deleteComment an optional comment explaining why the value is being deleted. + * @param deleteDate an optional timestamp indicating when the value was deleted. * @param requestingUser the user making the request. * @return the IRI of the value that was marked as deleted. */ @@ -1624,6 +1650,7 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde propertyIri: SmartIri, currentValue: ReadValueV2, deleteComment: Option[String], + deleteDate: Option[Instant], requestingUser: UserADM): Future[IRI] = { // Mark the existing version of the value as deleted. @@ -1648,8 +1675,9 @@ class ValuesResponderV2(responderData: ResponderData) extends Responder(responde val linkUpdateFuture = Future.sequence(linkUpdateFutures) - // Make a timestamp to indicate when the value was marked as deleted. - val currentTime: Instant = Instant.now + // If no custom delete date was provided, make a timestamp to indicate when the value was + // marked as deleted. + val currentTime: Instant = deleteDate.getOrElse(Instant.now) for { linkUpdates: Seq[SparqlTemplateLinkUpdate] <- linkUpdateFuture diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala index 9608bbed95..b2ebc666aa 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/v2/ResourcesRouteV2.scala @@ -553,9 +553,10 @@ class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) } } - private def deleteResourceTestRequestAndResponse: Future[Set[TestDataFileContent]] = { + private def deleteResourceTestRequestsAndResponses: Future[Set[TestDataFileContent]] = { val resourceIri = "http://rdfh.ch/0001/a-thing" val lastModificationDate = Instant.parse("2019-12-12T10:23:25.836924Z") + val deleteDate = Instant.parse("2020-08-14T10:00:00Z") FastFuture.successful( Set( @@ -566,6 +567,13 @@ class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) lastModificationDate = lastModificationDate ) ), + TestDataFileContent( + filePath = TestDataFilePath.makeJsonPath("delete-resource-with-custom-delete-date-request"), + text = SharedTestDataADM.deleteResourceWithCustomDeleteDate( + resourceIri = resourceIri, + deleteDate = deleteDate + ) + ), TestDataFileContent( filePath = TestDataFilePath.makeJsonPath("delete-resource-response"), text = SharedTestDataADM.successResponse("Resource marked as deleted")) @@ -630,7 +638,7 @@ class ResourcesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) previewResponse <- getResourcesPreviewTestResponse graphResponse <- getResourceGraphTestResponse metadataRequestsAndResponse <- updateResourceMetadataTestRequestsAndResponse - deleteRequestAndResponse <- deleteResourceTestRequestAndResponse + deleteRequestAndResponse <- deleteResourceTestRequestsAndResponses eraseRequest <- eraseResourceTestRequest } yield getResponses ++ createRequests ++ metadataRequestsAndResponse ++ deleteRequestAndResponse + previewResponse + graphResponse + eraseRequest 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 0282580440..02b8347227 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 @@ -210,7 +210,7 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit text = SharedTestDataADM.createIntValueWithCustomPermissionsRequest( resourceIri = SharedTestDataADM.AThing.iri, intValue = 4, - customPermissions = "CR knora-admin:Creator|V http://rdfh.ch/groups/0001/thing-searcher" + permissions = "CR knora-admin:Creator|V http://rdfh.ch/groups/0001/thing-searcher" ) ), TestDataFileContent( @@ -381,14 +381,14 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit ) ), TestDataFileContent( - filePath = TestDataFilePath.makeJsonPath("create-link-value-with-custom-Iri-UUID-CreationDate-request"), - text = SharedTestDataADM.createLinkValueWithCustomIriRequest( - resourceIri = SharedTestDataADM.AThing.iri, - targetResourceIri = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", - customValueIri = "http://rdfh.ch/0001/a-thing/values/link-Value-With-IRI", - customValueUUID = "IN4R19yYR0ygi3K2VEHpUQ", - customValueCreationDate = Instant.parse("2020-06-04T11:36:54.502951Z") - ) + filePath = TestDataFilePath.makeJsonPath("create-link-value-with-custom-Iri-UUID-CreationDate-request"), + text = SharedTestDataADM.createLinkValueWithCustomIriRequest( + resourceIri = SharedTestDataADM.AThing.iri, + targetResourceIri = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + valueIri = "http://rdfh.ch/0001/a-thing/values/link-Value-With-IRI", + valueUUID = "IN4R19yYR0ygi3K2VEHpUQ", + valueCreationDate = Instant.parse("2020-06-04T11:36:54.502951Z") + ) ) ) ) @@ -452,6 +452,8 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit * Returns JSON-LD requests for updating values in tests of generated client code. */ private def updateValueTestRequests: Future[Set[TestDataFileContent]] = { + val customValueCreationDate = Instant.parse("2020-08-14T10:00:00Z") + FastFuture.successful( Set( TestDataFileContent( @@ -462,13 +464,22 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit intValue = 5 ) ), + TestDataFileContent( + filePath = TestDataFilePath.makeJsonPath("update-int-value-request-with-custom-creation-date"), + text = SharedTestDataADM.updateIntValueWithCustomCreationDateRequest( + resourceIri = SharedTestDataADM.TestDing.iri, + valueIri = SharedTestDataADM.TestDing.intValueIri, + intValue = 5, + valueCreationDate = customValueCreationDate + ) + ), TestDataFileContent( filePath = TestDataFilePath.makeJsonPath("update-int-value-with-custom-permissions-request"), text = SharedTestDataADM.updateIntValueWithCustomPermissionsRequest( resourceIri = SharedTestDataADM.TestDing.iri, valueIri = SharedTestDataADM.TestDing.intValueIri, intValue = 6, - customPermissions = "CR http://rdfh.ch/groups/0001/thing-searcher" + permissions = "CR http://rdfh.ch/groups/0001/thing-searcher" ) ), TestDataFileContent( @@ -476,7 +487,7 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit text = SharedTestDataADM.updateIntValuePermissionsOnlyRequest( resourceIri = SharedTestDataADM.TestDing.iri, valueIri = SharedTestDataADM.TestDing.intValueIri, - customPermissions = "CR http://rdfh.ch/groups/0001/thing-searcher|V knora-admin:KnownUser" + permissions = "CR http://rdfh.ch/groups/0001/thing-searcher|V knora-admin:KnownUser" ) ), TestDataFileContent( @@ -697,6 +708,8 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit * Returns JSON-LD requests for deleting values in tests of generated client code. */ private def deleteValueTestRequests: Future[Set[TestDataFileContent]] = { + val deleteDate = Instant.parse("2020-08-14T10:00:00Z") + FastFuture.successful( Set( TestDataFileContent( @@ -707,6 +720,14 @@ class ValuesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) wit maybeDeleteComment = Some("this value was incorrect") ) ), + TestDataFileContent( + filePath = TestDataFilePath.makeJsonPath("delete-int-value-request-with-custom-delete-date"), + text = SharedTestDataADM.deleteIntValueRequest( + resourceIri = SharedTestDataADM.TestDing.iri, + valueIri = SharedTestDataADM.TestDing.intValueIri, + maybeDeleteComment = Some("this value was incorrect") + ) + ), TestDataFileContent( filePath = TestDataFilePath.makeJsonPath("delete-link-value-request"), text = SharedTestDataADM.deleteLinkValueRequest( diff --git a/webapi/src/main/scala/org/knora/webapi/sharedtestdata/SharedTestDataADM.scala b/webapi/src/main/scala/org/knora/webapi/sharedtestdata/SharedTestDataADM.scala index b3a942bc9b..8ed7bbb88b 100644 --- a/webapi/src/main/scala/org/knora/webapi/sharedtestdata/SharedTestDataADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/sharedtestdata/SharedTestDataADM.scala @@ -747,7 +747,7 @@ object SharedTestDataADM { | "@type" : "knora-api:IntValue", | "knora-api:intValueAsInt" : $intValue, | "knora-api:valueHasUUID" : "$valueUUID", - | "knora-api:creationDate" : { + | "knora-api:valueCreationDate" : { | "@type" : "xsd:dateTimeStamp", | "@value" : "$valueCreationDate" | } @@ -803,7 +803,7 @@ object SharedTestDataADM { | "anything:hasInteger" : { | "@type" : "knora-api:IntValue", | "knora-api:intValueAsInt" : $intValue, - | "knora-api:creationDate" : { + | "knora-api:valueCreationDate" : { | "@type" : "xsd:dateTimeStamp", | "@value" : "$creationDate" | } @@ -816,14 +816,14 @@ object SharedTestDataADM { |}""".stripMargin } - def createIntValueWithCustomPermissionsRequest(resourceIri: IRI, intValue: Int, customPermissions: String): String = { + def createIntValueWithCustomPermissionsRequest(resourceIri: IRI, intValue: Int, permissions: String): String = { s"""{ | "@id" : "$resourceIri", | "@type" : "anything:Thing", | "anything:hasInteger" : { | "@type" : "knora-api:IntValue", | "knora-api:intValueAsInt" : $intValue, - | "knora-api:hasPermissions" : "$customPermissions" + | "knora-api:hasPermissions" : "$permissions" | }, | "@context" : { | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", @@ -1190,23 +1190,23 @@ object SharedTestDataADM { def createLinkValueWithCustomIriRequest(resourceIri: IRI, targetResourceIri: IRI, - customValueIri: IRI, - customValueUUID: String, - customValueCreationDate: Instant): String = { + valueIri: IRI, + valueUUID: String, + valueCreationDate: Instant): String = { s"""{ | "@id" : "$resourceIri", | "@type" : "anything:Thing", | "anything:hasOtherThingValue" : { - | "@id" : "$customValueIri", + | "@id" : "$valueIri", | "@type" : "knora-api:LinkValue", | "knora-api:valueHasUUID": "IN4R19yYR0ygi3K2VEHpUQ", | "knora-api:linkValueHasTargetIri" : { | "@id" : "$targetResourceIri" | }, - | "knora-api:creationDate" : { + | "knora-api:valueCreationDate" : { | "@type" : "xsd:dateTimeStamp", - | "@value" : "$customValueCreationDate" - | } + | "@value" : "$valueCreationDate" + | } | }, | "@context" : { | "xsd" : "http://www.w3.org/2001/XMLSchema#", @@ -1235,10 +1235,34 @@ object SharedTestDataADM { |}""".stripMargin } + def updateIntValueWithCustomCreationDateRequest(resourceIri: IRI, + valueIri: IRI, + intValue: Int, + valueCreationDate: Instant): String = { + s"""{ + | "@id" : "$resourceIri", + | "@type" : "anything:Thing", + | "anything:hasInteger" : { + | "@id" : "$valueIri", + | "@type" : "knora-api:IntValue", + | "knora-api:intValueAsInt" : $intValue, + | "knora-api:valueCreationDate" : { + | "@type" : "xsd:dateTimeStamp", + | "@value" : "$valueCreationDate" + | } + | }, + | "@context" : { + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "anything" : "http://0.0.0.0:3333/ontology/0001/anything/v2#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin + } + def updateIntValueWithCustomPermissionsRequest(resourceIri: IRI, valueIri: IRI, intValue: Int, - customPermissions: String): String = { + permissions: String): String = { s"""{ | "@id" : "$resourceIri", | "@type" : "anything:Thing", @@ -1246,7 +1270,7 @@ object SharedTestDataADM { | "@id" : "$valueIri", | "@type" : "knora-api:IntValue", | "knora-api:intValueAsInt" : $intValue, - | "knora-api:hasPermissions" : "$customPermissions" + | "knora-api:hasPermissions" : "$permissions" | }, | "@context" : { | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", @@ -1257,14 +1281,14 @@ object SharedTestDataADM { def updateIntValuePermissionsOnlyRequest(resourceIri: IRI, valueIri: IRI, - customPermissions: String): String = { + permissions: String): String = { s"""{ | "@id" : "$resourceIri", | "@type" : "anything:Thing", | "anything:hasInteger" : { | "@id" : "$valueIri", | "@type" : "knora-api:IntValue", - | "knora-api:hasPermissions" : "$customPermissions" + | "knora-api:hasPermissions" : "$permissions" | }, | "@context" : { | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", @@ -1719,6 +1743,28 @@ object SharedTestDataADM { } } + def deleteIntValueRequestWithCustomDeleteDate(resourceIri: IRI, + valueIri: IRI, + deleteDate: Instant): String = { + s"""{ + | "@id" : "$resourceIri", + | "@type" : "anything:Thing", + | "anything:hasInteger" : { + | "@id" : "$valueIri", + | "@type" : "knora-api:IntValue", + | "knora-api:deleteDate" : { + | "@type" : "xsd:dateTimeStamp", + | "@value" : "$deleteDate" + | } + | }, + | "@context" : { + | "xsd" : "http://www.w3.org/2001/XMLSchema#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "anything" : "http://0.0.0.0:3333/ontology/0001/anything/v2#" + | } + |}""".stripMargin + } + def deleteLinkValueRequest(resourceIri: IRI, valueIri: IRI): String = { s"""{ @@ -1920,9 +1966,9 @@ object SharedTestDataADM { |}""".stripMargin } - def createResourceWithCustomIRI(customIRI: IRI): String = { + def createResourceWithCustomIRI(iri: IRI): String = { s"""{ - | "@id" : "$customIRI", + | "@id" : "$iri", | "@type" : "anything:Thing", | "knora-api:attachedToProject" : { | "@id" : "http://rdfh.ch/projects/0001" @@ -1942,14 +1988,14 @@ object SharedTestDataADM { |}""".stripMargin } - def createResourceWithCustomValueIRI(customValueIRI: IRI): String = { + def createResourceWithCustomValueIRI(valueIRI: IRI): String = { s"""{ | "@type" : "anything:Thing", | "knora-api:attachedToProject" : { | "@id" : "http://rdfh.ch/projects/0001" | }, | "anything:hasBoolean" : { - | "@id" : "$customValueIRI", + | "@id" : "$valueIRI", | "@type" : "knora-api:BooleanValue", | "knora-api:booleanValueAsBoolean" : true | }, @@ -1964,7 +2010,7 @@ object SharedTestDataADM { |}""".stripMargin } - def createResourceWithCustomValueUUID(customValueUUID: String): String = { + def createResourceWithCustomValueUUID(valueUUID: String): String = { s"""{ | "@type" : "anything:Thing", | "knora-api:attachedToProject" : { @@ -1973,7 +2019,7 @@ object SharedTestDataADM { | "anything:hasBoolean" : { | "@type" : "knora-api:BooleanValue", | "knora-api:booleanValueAsBoolean" : true, - | "knora-api:valueHasUUID" : "$customValueUUID" + | "knora-api:valueHasUUID" : "$valueUUID" | }, | "rdfs:label" : "test thing", | "@context" : { @@ -1995,7 +2041,7 @@ object SharedTestDataADM { | "anything:hasBoolean" : { | "@type" : "knora-api:BooleanValue", | "knora-api:booleanValueAsBoolean" : false, - | "knora-api:creationDate" : { + | "knora-api:valueCreationDate" : { | "@type" : "xsd:dateTimeStamp", | "@value" : "$creationDate" | } @@ -2012,26 +2058,26 @@ object SharedTestDataADM { } - def createResourceWithCustomResourceIriAndCreationDateAndValueWithCustomIRIAndUUID(customResourceIRI: IRI, - customCreationDate: Instant, - customValueIRI: IRI, - customValueUUID: String): String = { + def createResourceWithCustomResourceIriAndCreationDateAndValueWithCustomIRIAndUUID(resourceIRI: IRI, + creationDate: Instant, + valueIRI: IRI, + valueUUID: String): String = { s"""{ - | "@id" : "$customResourceIRI", + | "@id" : "$resourceIRI", | "@type" : "anything:Thing", | "knora-api:attachedToProject" : { | "@id" : "http://rdfh.ch/projects/0001" | }, | "anything:hasBoolean" : { - | "@id": "$customValueIRI", + | "@id": "$valueIRI", | "@type" : "knora-api:BooleanValue", | "knora-api:booleanValueAsBoolean" : true, - | "knora-api:valueHasUUID" : "$customValueUUID" + | "knora-api:valueHasUUID" : "$valueUUID" | }, | "rdfs:label" : "test thing", | "knora-api:creationDate" : { | "@type" : "xsd:dateTimeStamp", - | "@value" : "$customCreationDate" + | "@value" : "$creationDate" | }, | "@context" : { | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", @@ -2146,6 +2192,26 @@ object SharedTestDataADM { |}""".stripMargin } + def deleteResourceWithCustomDeleteDate(resourceIri: IRI, + deleteDate: Instant): String = { + s"""|{ + | "@id" : "$resourceIri", + | "@type" : "anything:Thing", + | "knora-api:deleteComment" : "This resource is too boring.", + | "knora-api:deleteDate" : { + | "@type" : "xsd:dateTimeStamp", + | "@value" : "$deleteDate" + | }, + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#", + | "anything" : "http://0.0.0.0:3333/ontology/0001/anything/v2#" + | } + |}""".stripMargin + } + def eraseResource(resourceIri: IRI, lastModificationDate: Instant): String = { s"""|{ diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/getDeleteDate.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/getDeleteDate.scala.txt new file mode 100644 index 0000000000..8b81853797 --- /dev/null +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/getDeleteDate.scala.txt @@ -0,0 +1,45 @@ +@* + * Copyright © 2015-2019 the contributors (see Contributors.md). + * + * This file is part of Knora. + * + * Knora is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Knora is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with Knora. If not, see . + *@ + +@import org.knora.webapi.IRI + +@* + * Gets the delete date of a resource or value. + * + * @param triplestore the name of the triplestore being used. + * @param entityIri the IRI of the resource or value. + *@ +@(triplestore: String, + entityIri: IRI) + +PREFIX rdf: +PREFIX rdfs: +PREFIX owl: +PREFIX xsd: +PREFIX knora-base: + +SELECT ?deleteDate +@* Ensure that inference is not used in this query. *@ +@if(triplestore.startsWith("graphdb")) { + FROM +} +WHERE { + BIND(IRI("@entityIri") AS ?entity) + ?entity knora-base:deleteDate ?deleteDate . +} diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/BUILD.bazel b/webapi/src/test/scala/org/knora/webapi/e2e/v2/BUILD.bazel index f8129d4ba9..9d5701a32f 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/BUILD.bazel +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/BUILD.bazel @@ -89,9 +89,9 @@ scala_test( ) ## -## //webapi/src/test/scala/org/knora/webapi/e2e/v2:RessourcesRouteV2E2ESpec +## //webapi/src/test/scala/org/knora/webapi/e2e/v2:ResourcesRouteV2E2ESpec scala_test( - name = "RessourcesRouteV2E2ESpec", + name = "ResourcesRouteV2E2ESpec", size = "medium", # 300s srcs = [ "ResourcesRouteV2E2ESpec.scala", diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala index aa72c924c3..974df8acc3 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ResourcesRouteV2E2ESpec.scala @@ -675,7 +675,7 @@ class ResourcesRouteV2E2ESpec extends E2ESpec(ResourcesRouteV2E2ESpec.config) { "create a resource with random resource Iri and custom value UUIDs" in { val customValueUUID = SharedTestDataADM.customValueUUID - val jsonLDEntity = SharedTestDataADM.createResourceWithCustomValueUUID(customValueUUID = customValueUUID) + val jsonLDEntity = SharedTestDataADM.createResourceWithCustomValueUUID(valueUUID = customValueUUID) val request = Post(s"$baseApiUrl/v2/resources", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) @@ -728,10 +728,10 @@ class ResourcesRouteV2E2ESpec extends E2ESpec(ResourcesRouteV2E2ESpec.config) { val customValueIRI: IRI = SharedTestDataADM.customValueIRI_withResourceIriAndValueIRIAndValueUUID val customValueUUID = SharedTestDataADM.customValueUUID val jsonLDEntity = SharedTestDataADM.createResourceWithCustomResourceIriAndCreationDateAndValueWithCustomIRIAndUUID( - customResourceIRI = customResourceIRI, - customCreationDate = customCreationDate, - customValueIRI = customValueIRI, - customValueUUID = customValueUUID) + resourceIRI = customResourceIRI, + creationDate = customCreationDate, + valueIRI = customValueIRI, + valueUUID = customValueUUID) val request = Post(s"$baseApiUrl/v2/resources", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) @@ -887,6 +887,28 @@ class ResourcesRouteV2E2ESpec extends E2ESpec(ResourcesRouteV2E2ESpec.config) { assert(previewResponse.status == StatusCodes.NotFound, previewResponseAsString) } + "mark a resource as deleted, supplying a custom delete date" in { + val resourceIri = "http://rdfh.ch/0001/5IEswyQFQp2bxXDrOyEfEA" + val deleteDate = Instant.now + + val jsonLDEntity = SharedTestDataADM.deleteResourceWithCustomDeleteDate( + resourceIri = resourceIri, + deleteDate = deleteDate + ) + + val updateRequest = Post(s"$baseApiUrl/v2/resources/delete", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity)) ~> addCredentials(BasicHttpCredentials(SharedTestDataADM.superUser.email, password)) + val updateResponse: HttpResponse = singleAwaitingRequest(updateRequest) + val updateResponseAsString: String = responseToString(updateResponse) + assert(updateResponse.status == StatusCodes.OK, updateResponseAsString) + assert(updateResponseAsString == SharedTestDataADM.successResponse("Resource marked as deleted")) + + val previewRequest = Get(s"$baseApiUrl/v2/resourcespreview/${URLEncoder.encode(resourceIri, "UTF-8")}") ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) + val previewResponse: HttpResponse = singleAwaitingRequest(previewRequest) + val previewResponseAsString = responseToString(previewResponse) + assert(previewResponse.status == StatusCodes.NotFound, previewResponseAsString) + } + + "create a resource with a large text containing a lot of markup (32849 words, 6738 standoff tags)" ignore { // uses too much memory for GitHub CI // Create a resource containing the text of Hamlet. diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala index 036bebecf4..5429fba7ef 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/ValuesRouteV2E2ESpec.scala @@ -54,6 +54,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { private val password = "test" private val intValueIri = new MutableTestIri + private val intValueIriWithCustomCreationDate = new MutableTestIri private val textValueWithoutStandoffIri = new MutableTestIri private val textValueWithStandoffIri = new MutableTestIri private val textValueWithEscapeIri = new MutableTestIri @@ -253,12 +254,11 @@ class ValuesRouteV2E2ESpec extends E2ESpec { savedIntValue should ===(intValue) } - "create an integer value with a custom valueIri" in { + "create an integer value with a custom value IRI" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri - val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri val intValue: Int = 30 - val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) val customValueIri: IRI = "http://rdfh.ch/0001/a-customized-thing/values/int-with-valueIRI" + val jsonLdEntity = SharedTestDataADM.createIntValueWithCustomValueIriRequest( resourceIri = resourceIri, intValue = intValue, @@ -267,14 +267,13 @@ class ValuesRouteV2E2ESpec extends E2ESpec { val request = Post(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) - assert(response.status == StatusCodes.OK, response.toString) val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response) val valueIri: IRI = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.ID, stringFormatter.validateAndEscapeIri) assert(valueIri == customValueIri) } - "return a DuplicateValueException during value creation when the supplied value Iri is not unique" in { + "return a DuplicateValueException during value creation when the supplied value IRI is not unique" in { // duplicate value IRI val params = @@ -297,16 +296,14 @@ class ValuesRouteV2E2ESpec extends E2ESpec { val response: HttpResponse = singleAwaitingRequest(request) assert(response.status == StatusCodes.BadRequest, response.toString) - val errorMessage : String = Await.result(Unmarshal(response.entity).to[String], 1.second) + val errorMessage: String = Await.result(Unmarshal(response.entity).to[String], 1.second) val invalidIri: Boolean = errorMessage.contains(s"IRI: 'http://rdfh.ch/0001/a-customized-thing/values/int-with-valueIRI' already exists, try another one.") invalidIri should be(true) } "create an integer value with a custom UUID" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri - val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri val intValue: Int = 45 - val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) val customValueUUID = "IN4R19yYR0ygi3K2VEHpUQ" val jsonLdEntity = SharedTestDataADM.createIntValueWithCustomUUIDRequest( resourceIri = resourceIri, @@ -326,54 +323,56 @@ class ValuesRouteV2E2ESpec extends E2ESpec { "create an integer value with a custom creation date" in { val customCreationDate: Instant = Instant.parse("2020-06-04T11:36:54.502951Z") val resourceIri: IRI = SharedTestDataADM.AThing.iri - val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri val intValue: Int = 25 - val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) val jsonLdEntity = SharedTestDataADM.createIntValueWithCustomCreationDateRequest(resourceIri = resourceIri, intValue = intValue, creationDate = customCreationDate) val request = Post(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) - assert(response.status == StatusCodes.OK, response.toString) val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response) + + val valueIri: IRI = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.ID, stringFormatter.validateAndEscapeIri) + intValueIriWithCustomCreationDate.set(valueIri) + val savedCreationDate: Instant = responseJsonDoc.body.requireDatatypeValueInObject( key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, validationFun = stringFormatter.xsdDateTimeStampToInstant ) + assert(savedCreationDate == customCreationDate) } - "create an integer value with custom Iri, UUID, and creation Date" in { + "create an integer value with custom IRI, UUID, and creation date" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri - val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri val intValue: Int = 10 - val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) val customValueIri: IRI = "http://rdfh.ch/0001/a-thing/values/int-with-IRI" val customValueUUID = "IN4R19yYR0ygi3K2VEHpUQ" val customCreationDate: Instant = Instant.parse("2020-06-04T12:58:54.502951Z") + val jsonLdEntity = SharedTestDataADM.createIntValueWithCustomIRIRequest( - resourceIri = resourceIri, - intValue = intValue, - valueIri = customValueIri, - valueUUID = customValueUUID, - valueCreationDate = customCreationDate - ) + resourceIri = resourceIri, + intValue = intValue, + valueIri = customValueIri, + valueUUID = customValueUUID, + valueCreationDate = customCreationDate + ) val request = Post(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) - assert(response.status == StatusCodes.OK, response.toString) val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response) val valueIri: IRI = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.ID, stringFormatter.validateAndEscapeIri) assert(valueIri == customValueIri) val valueUUID = responseJsonDoc.body.requireString(OntologyConstants.KnoraApiV2Complex.ValueHasUUID) assert(valueUUID == customValueUUID) + val savedCreationDate: Instant = responseJsonDoc.body.requireDatatypeValueInObject( key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, validationFun = stringFormatter.xsdDateTimeStampToInstant ) + assert(savedCreationDate == customCreationDate) } @@ -408,7 +407,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { val jsonLdEntity = SharedTestDataADM.createIntValueWithCustomPermissionsRequest( resourceIri = resourceIri, intValue = intValue, - customPermissions = customPermissions + permissions = customPermissions ) val request = Post(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) @@ -468,7 +467,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { val savedValueAsString: String = savedValue.requireString(OntologyConstants.KnoraApiV2Complex.ValueAsString) savedValueAsString should ===(valueAsString) } - + "not update a text value without a comment without changing it" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri val valueAsString: String = "text without standoff" @@ -1995,36 +1994,36 @@ class ValuesRouteV2E2ESpec extends E2ESpec { savedTargetIri should ===(SharedTestDataADM.TestDing.iri) } - "create a link between two resources with a custom link value Iri, UUID, creationDate" in { + "create a link between two resources with a custom link value IRI, UUID, creationDate" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri val targetResourceIri: IRI = "http://rdfh.ch/0001/CNhWoNGGT7iWOrIwxsEqvA" val customValueIri: IRI = "http://rdfh.ch/0001/a-thing/values/link-Value-With-IRI" val customValueUUID = "IN4R19yYR0ygi3K2VEHpUQ" val customCreationDate: Instant = Instant.parse("2020-06-04T11:36:54.502951Z") - val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) val jsonLdEntity = SharedTestDataADM.createLinkValueWithCustomIriRequest( resourceIri = resourceIri, targetResourceIri = targetResourceIri, - customValueIri = customValueIri, - customValueUUID = customValueUUID, - customValueCreationDate = customCreationDate + valueIri = customValueIri, + valueUUID = customValueUUID, + valueCreationDate = customCreationDate ) val request = Post(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) val response: HttpResponse = singleAwaitingRequest(request) assert(response.status == StatusCodes.OK, response.toString) val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response) - val valueIri: IRI = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.ID, stringFormatter.validateAndEscapeIri) assert(valueIri == customValueIri) val valueUUID: IRI = responseJsonDoc.body.requireString(OntologyConstants.KnoraApiV2Complex.ValueHasUUID) assert(valueUUID == customValueUUID) + val savedCreationDate: Instant = responseJsonDoc.body.requireDatatypeValueInObject( key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, validationFun = stringFormatter.xsdDateTimeStampToInstant ) + assert(savedCreationDate == customCreationDate) } @@ -2050,7 +2049,6 @@ class ValuesRouteV2E2ESpec extends E2ESpec { valueType should ===(OntologyConstants.KnoraApiV2Complex.IntValue.toSmartIri) val newIntegerValueUUID: UUID = responseJsonDoc.body.requireStringWithValidation(OntologyConstants.KnoraApiV2Complex.ValueHasUUID, stringFormatter.validateBase64EncodedUuid) assert(newIntegerValueUUID == integerValueUUID) // The new version should have the same UUID. - integerValueUUID = newIntegerValueUUID val savedValue: JsonLDObject = getValue( resourceIri = resourceIri, @@ -2065,6 +2063,52 @@ class ValuesRouteV2E2ESpec extends E2ESpec { intValueAsInt should ===(intValue) } + "update an integer value with a custom creation date" in { + val resourceIri: IRI = SharedTestDataADM.AThing.iri + val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri + val intValue: Int = 6 + val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) + val valueCreationDate = Instant.now + + val jsonLdEntity = SharedTestDataADM.updateIntValueWithCustomCreationDateRequest( + resourceIri = resourceIri, + valueIri = intValueIri.get, + intValue = intValue, + valueCreationDate = valueCreationDate + ) + + val request = Put(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) + val response: HttpResponse = singleAwaitingRequest(request) + assert(response.status == StatusCodes.OK, response.toString) + val responseJsonDoc: JsonLDDocument = responseToJsonLDDocument(response) + val valueIri: IRI = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.ID, stringFormatter.validateAndEscapeIri) + intValueIri.set(valueIri) + val valueType: SmartIri = responseJsonDoc.body.requireStringWithValidation(JsonLDConstants.TYPE, stringFormatter.toSmartIriWithErr) + valueType should ===(OntologyConstants.KnoraApiV2Complex.IntValue.toSmartIri) + val newIntegerValueUUID: UUID = responseJsonDoc.body.requireStringWithValidation(OntologyConstants.KnoraApiV2Complex.ValueHasUUID, stringFormatter.validateBase64EncodedUuid) + assert(newIntegerValueUUID == integerValueUUID) // The new version should have the same UUID. + + val savedValue: JsonLDObject = getValue( + resourceIri = resourceIri, + maybePreviousLastModDate = maybeResourceLastModDate, + propertyIriForGravsearch = propertyIri, + propertyIriInResult = propertyIri, + expectedValueIri = intValueIri.get, + userEmail = anythingUserEmail + ) + + val intValueAsInt: Int = savedValue.requireInt(OntologyConstants.KnoraApiV2Complex.IntValueAsInt) + intValueAsInt should ===(intValue) + + val savedCreationDate: Instant = savedValue.requireDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.ValueCreationDate, + expectedDatatype = OntologyConstants.Xsd.DateTimeStamp.toSmartIri, + validationFun = stringFormatter.xsdDateTimeStampToInstant + ) + + savedCreationDate should ===(valueCreationDate) + } + "not update an integer value if the simple schema is submitted" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri val intValue: Int = 10 @@ -2093,7 +2137,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { "update an integer value with custom permissions" in { val resourceIri: IRI = SharedTestDataADM.AThing.iri val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri - val intValue: Int = 6 + val intValue: Int = 7 val customPermissions: String = "CR http://rdfh.ch/groups/0001/thing-searcher" val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUserEmail) @@ -2101,7 +2145,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { resourceIri = resourceIri, valueIri = intValueIri.get, intValue = intValue, - customPermissions = customPermissions + permissions = customPermissions ) val request = Put(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) @@ -2137,7 +2181,7 @@ class ValuesRouteV2E2ESpec extends E2ESpec { val jsonLDEntity = SharedTestDataADM.updateIntValuePermissionsOnlyRequest( resourceIri = resourceIri, valueIri = intValueIri.get, - customPermissions = customPermissions + permissions = customPermissions ) val request = Put(baseApiUrl + "/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLDEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) @@ -3127,6 +3171,20 @@ class ValuesRouteV2E2ESpec extends E2ESpec { assert(response.status == StatusCodes.OK, response.toString) } + "delete an integer value, supplying a custom delete date" in { + val deleteDate = Instant.now + + val jsonLdEntity = SharedTestDataADM.deleteIntValueRequestWithCustomDeleteDate( + resourceIri = SharedTestDataADM.AThing.iri, + valueIri = intValueIriWithCustomCreationDate.get, + deleteDate = deleteDate + ) + + val request = Post(baseApiUrl + "/v2/values/delete", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials(BasicHttpCredentials(anythingUserEmail, password)) + val response: HttpResponse = singleAwaitingRequest(request) + assert(response.status == StatusCodes.OK, response.toString) + } + "not delete an integer value if the simple schema is submitted" in { val jsonLdEntity = s"""{ diff --git a/webapi/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala index ea479790df..8c800a9c58 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/v2/ResourcesResponderV2Spec.scala @@ -20,6 +20,7 @@ package org.knora.webapi.responders.v2 import java.time.Instant +import java.time.temporal.ChronoUnit import java.util.UUID import akka.actor.{ActorRef, Props} @@ -525,6 +526,25 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { } } + private def getDeleteDate(resourceIri: IRI): Instant = { + val sparqlQuery: String = org.knora.webapi.messages.twirl.queries.sparql.v2.txt.getDeleteDate( + triplestore = settings.triplestoreType, + entityIri = resourceIri + ).toString() + + storeManager ! SparqlSelectRequest(sparqlQuery) + + expectMsgPF(timeout) { + case sparqlSelectResponse: SparqlSelectResponse => + val savedDeleteDateStr = sparqlSelectResponse.getFirstRow.rowMap("deleteDate") + + stringFormatter.xsdDateTimeStampToInstant( + savedDeleteDateStr, + throw AssertionException(s"Couldn't parse delete date from triplestore: $savedDeleteDateStr") + ) + } + } + // The default timeout for receiving reply messages from actors. private val timeout = 10.seconds @@ -1740,6 +1760,26 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { aThingLastModificationDate = updatedLastModificationDate } + "not mark a resource as deleted with a custom delete date that is earlier than the resource's last modification date" in { + val deleteDate: Instant = aThingLastModificationDate.minus(1, ChronoUnit.DAYS) + + val deleteRequest = DeleteOrEraseResourceRequestV2( + resourceIri = aThingIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + maybeDeleteComment = Some("This resource is too boring."), + maybeDeleteDate = Some(deleteDate), + maybeLastModificationDate = Some(aThingLastModificationDate), + requestingUser = SharedTestDataADM.anythingUser1, + apiRequestID = UUID.randomUUID + ) + + responderManager ! deleteRequest + + expectMsgPF(timeout) { + case msg: akka.actor.Status.Failure => msg.cause.isInstanceOf[BadRequestException] should ===(true) + } + } + "mark a resource as deleted" in { val deleteRequest = DeleteOrEraseResourceRequestV2( resourceIri = aThingIri, @@ -1763,6 +1803,36 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { } } + "mark a resource as deleted, supplying a custom delete date" in { + val resourceIri = "http://rdfh.ch/0001/5IEswyQFQp2bxXDrOyEfEA" + val deleteDate: Instant = Instant.now + + val deleteRequest = DeleteOrEraseResourceRequestV2( + resourceIri = resourceIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + maybeDeleteComment = Some("This resource is too boring."), + maybeDeleteDate = Some(deleteDate), + maybeLastModificationDate = None, + requestingUser = SharedTestDataADM.superUser, + apiRequestID = UUID.randomUUID + ) + + responderManager ! deleteRequest + + expectMsgType[SuccessResponseV2](timeout) + + // We should now be unable to request the resource. + + responderManager ! ResourcesGetRequestV2(resourceIris = Seq(resourceIri), targetSchema = ApiV2Complex, requestingUser = SharedTestDataADM.anythingUser1) + + expectMsgPF(timeout) { + case msg: akka.actor.Status.Failure => msg.cause.isInstanceOf[NotFoundException] should ===(true) + } + + val savedDeleteDate: Instant = getDeleteDate(resourceIri) + assert(savedDeleteDate == deleteDate) + } + "not accept custom resource permissions that would give the requesting user a higher permission on a resource than the default" in { val resourceIri: IRI = stringFormatter.makeRandomResourceIri(SharedTestDataADM.imagesProject.shortcode) diff --git a/webapi/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala index e1c2703a3f..c568cfb8d5 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/v2/ValuesResponderV2Spec.scala @@ -77,6 +77,7 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { private val firstIntValueVersionIri = new MutableTestIri private val intValueIri = new MutableTestIri private val intValueIriWithCustomPermissions = new MutableTestIri + private val intValueIriWithCustomUuidAndTimestamp = new MutableTestIri private val zeitglöckleinCommentWithoutStandoffIri = new MutableTestIri private val zeitglöckleinCommentWithStandoffIri = new MutableTestIri private val zeitglöckleinCommentWithCommentIri = new MutableTestIri @@ -203,6 +204,7 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { propertyIriForGravsearch: SmartIri, propertyIriInResult: SmartIri, valueIri: IRI, + customDeleteDate: Option[Instant] = None, requestingUser: UserADM): Unit = { val resource = getResourceWithValues( resourceIri = resourceIri, @@ -222,6 +224,31 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { case Some(_) => throw AssertionException(s"Value <$valueIri was not deleted>") case None => () } + + // If a custom delete date was used, check that it was saved correctly. + customDeleteDate match { + case Some(deleteDate) => + val sparqlQuery: String = org.knora.webapi.messages.twirl.queries.sparql.v2.txt.getDeleteDate( + triplestore = settings.triplestoreType, + entityIri = valueIri + ).toString() + + storeManager ! SparqlSelectRequest(sparqlQuery) + + expectMsgPF(timeout) { + case sparqlSelectResponse: SparqlSelectResponse => + val savedDeleteDateStr = sparqlSelectResponse.getFirstRow.rowMap("deleteDate") + + val savedDeleteDate: Instant = stringFormatter.xsdDateTimeStampToInstant( + savedDeleteDateStr, + throw AssertionException(s"Couldn't parse delete date from triplestore: $savedDeleteDateStr") + ) + + assert(savedDeleteDate == deleteDate) + } + + case None => () + } } private def checkLastModDate(resourceIri: IRI, maybePreviousLastModDate: Option[Instant], maybeUpdatedLastModDate: Option[Instant]): Unit = { @@ -445,7 +472,6 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { case updateValueResponse: UpdateValueResponseV2 => intValueIri.set(updateValueResponse.valueIri) assert(updateValueResponse.valueUUID == integerValueUUID) - integerValueUUID = updateValueResponse.valueUUID } // Read the value back to check that it was added correctly. @@ -809,6 +835,146 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { } } + "create an integer value with custom UUID and creation date" in { + // Add the value. + + val resourceIri: IRI = aThingIri + val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri + val intValue = 987 + val valueUUID = UUID.randomUUID + val valueCreationDate = Instant.now + val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUser1) + + responderManager ! CreateValueRequestV2( + CreateValueV2( + resourceIri = resourceIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + propertyIri = propertyIri, + valueContent = IntegerValueContentV2( + ontologySchema = ApiV2Complex, + valueHasInteger = intValue + ), + valueUUID = Some(valueUUID), + valueCreationDate = Some(valueCreationDate) + ), + requestingUser = anythingUser1, + apiRequestID = UUID.randomUUID + ) + + expectMsgPF(timeout) { + case createValueResponse: CreateValueResponseV2 => intValueIriWithCustomUuidAndTimestamp.set(createValueResponse.valueIri) + } + + // Read the value back to check that it was added correctly. + + val valueFromTriplestore = getValue( + resourceIri = resourceIri, + maybePreviousLastModDate = maybeResourceLastModDate, + propertyIriForGravsearch = propertyIri, + propertyIriInResult = propertyIri, + expectedValueIri = intValueIriWithCustomUuidAndTimestamp.get, + requestingUser = anythingUser1 + ) + + valueFromTriplestore.valueContent match { + case savedValue: IntegerValueContentV2 => + savedValue.valueHasInteger should ===(intValue) + valueFromTriplestore.valueHasUUID should ===(valueUUID) + valueFromTriplestore.valueCreationDate should ===(valueCreationDate) + + case _ => throw AssertionException(s"Expected integer value, got $valueFromTriplestore") + } + } + + "not update an integer value with a custom creation date that is earlier than the date of the current version" in { + val resourceIri: IRI = aThingIri + val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri + val intValue = 989 + val valueCreationDate = Instant.parse("2019-11-29T10:00:00Z") + + responderManager ! UpdateValueRequestV2( + UpdateValueContentV2( + resourceIri = resourceIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + propertyIri = propertyIri, + valueIri = intValueIriWithCustomUuidAndTimestamp.get, + valueContent = IntegerValueContentV2( + ontologySchema = ApiV2Complex, + valueHasInteger = intValue + ), + valueCreationDate = Some(valueCreationDate) + ), + requestingUser = anythingUser1, + apiRequestID = UUID.randomUUID + ) + + expectMsgPF(timeout) { + case msg: akka.actor.Status.Failure => assert(msg.cause.isInstanceOf[BadRequestException]) + } + } + + "update an integer value with a custom creation date" in { + val resourceIri: IRI = aThingIri + val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri + val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUser1) + + // Get the value before update. + val previousValueFromTriplestore: ReadValueV2 = getValue( + resourceIri = resourceIri, + maybePreviousLastModDate = maybeResourceLastModDate, + propertyIriForGravsearch = propertyIri, + propertyIriInResult = propertyIri, + expectedValueIri = intValueIriWithCustomUuidAndTimestamp.get, + requestingUser = anythingUser1, + checkLastModDateChanged = false + ) + + // Update the value. + + val intValue = 988 + val valueCreationDate = Instant.now + + responderManager ! UpdateValueRequestV2( + UpdateValueContentV2( + resourceIri = resourceIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + propertyIri = propertyIri, + valueIri = intValueIriWithCustomUuidAndTimestamp.get, + valueContent = IntegerValueContentV2( + ontologySchema = ApiV2Complex, + valueHasInteger = intValue + ), + valueCreationDate = Some(valueCreationDate) + ), + requestingUser = anythingUser1, + apiRequestID = UUID.randomUUID + ) + + expectMsgPF(timeout) { + case updateValueResponse: UpdateValueResponseV2 => + intValueIriWithCustomUuidAndTimestamp.set(updateValueResponse.valueIri) + } + + // Read the value back to check that it was added correctly. + + val updatedValueFromTriplestore = getValue( + resourceIri = resourceIri, + maybePreviousLastModDate = maybeResourceLastModDate, + propertyIriForGravsearch = propertyIri, + propertyIriInResult = propertyIri, + expectedValueIri = intValueIriWithCustomUuidAndTimestamp.get, + requestingUser = anythingUser1 + ) + + updatedValueFromTriplestore.valueContent match { + case savedValue: IntegerValueContentV2 => + savedValue.valueHasInteger should ===(intValue) + updatedValueFromTriplestore.valueCreationDate should ===(valueCreationDate) + + case _ => throw AssertionException(s"Expected integer value, got $updatedValueFromTriplestore") + } + } + "not create a value if the user does not have modify permission on the resource" in { val resourceIri: IRI = aThingIri val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri @@ -3989,6 +4155,37 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { requestingUser = anythingUser1) } + "delete an integer value, specifying a custom delete date" in { + val resourceIri: IRI = aThingIri + val propertyIri: SmartIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#hasInteger".toSmartIri + val maybeResourceLastModDate: Option[Instant] = getResourceLastModificationDate(resourceIri, anythingUser1) + val deleteDate: Instant = Instant.now + + responderManager ! DeleteValueRequestV2( + resourceIri = resourceIri, + resourceClassIri = "http://0.0.0.0:3333/ontology/0001/anything/v2#Thing".toSmartIri, + propertyIri = propertyIri, + valueIri = intValueIriWithCustomUuidAndTimestamp.get, + valueTypeIri = OntologyConstants.KnoraApiV2Complex.IntValue.toSmartIri, + deleteComment = Some("this value was incorrect"), + deleteDate = Some(deleteDate), + requestingUser = anythingUser1, + apiRequestID = UUID.randomUUID + ) + + expectMsgType[SuccessResponseV2](timeout) + + checkValueIsDeleted( + resourceIri = resourceIri, + maybePreviousLastModDate = maybeResourceLastModDate, + propertyIriForGravsearch = propertyIri, + propertyIriInResult = propertyIri, + valueIri = intValueIriWithCustomUuidAndTimestamp.get, + customDeleteDate = Some(deleteDate), + requestingUser = anythingUser1 + ) + } + "not delete a standoff link directly" in { responderManager ! DeleteValueRequestV2( resourceIri = zeitglöckleinIri, @@ -4022,12 +4219,14 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { expectMsgType[SuccessResponseV2](timeout) - checkValueIsDeleted(resourceIri = zeitglöckleinIri, + checkValueIsDeleted( + resourceIri = zeitglöckleinIri, maybePreviousLastModDate = maybeResourceLastModDate, propertyIriForGravsearch = propertyIri, propertyIriInResult = propertyIri, valueIri = zeitglöckleinCommentWithStandoffIri.get, - requestingUser = incunabulaUser) + requestingUser = incunabulaUser + ) // There should be no standoff link values left in the resource. @@ -4058,12 +4257,14 @@ class ValuesResponderV2Spec extends CoreSpec() with ImplicitSender { expectMsgType[SuccessResponseV2](timeout) - checkValueIsDeleted(resourceIri = resourceIri, + checkValueIsDeleted( + resourceIri = resourceIri, maybePreviousLastModDate = maybeResourceLastModDate, propertyIriForGravsearch = linkPropertyIri, propertyIriInResult = linkValuePropertyIri, valueIri = linkValueIri.get, - requestingUser = anythingUser1) + requestingUser = anythingUser1 + ) } "not delete a value if the property's cardinality doesn't allow it" in {