From 0eb199a4a5bb7431e279fdfcb5f95bbab84599f9 Mon Sep 17 00:00:00 2001 From: Sepideh Alassi Date: Mon, 19 Apr 2021 21:14:01 +0200 Subject: [PATCH 1/7] fix (lists): escape special characters before making SPARQL statement, unescape when returning response --- .../webapi/messages/StringFormatter.scala | 14 ++- .../listsmessages/ListsMessagesADM.scala | 87 +++++++++++++++++++ .../responders/admin/ListsResponderADM.scala | 14 +-- .../admin/lists/NewListsRouteADMFeature.scala | 4 +- .../admin/lists/OldListsRouteADMFeature.scala | 4 +- .../admin/ListsResponderADMSpec.scala | 53 +++++++++-- 6 files changed, 159 insertions(+), 17 deletions(-) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 1601cc0b83..6497619d34 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -39,7 +39,7 @@ import org.knora.webapi._ import org.knora.webapi.exceptions._ import org.knora.webapi.messages.IriConversions._ import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM -import org.knora.webapi.messages.store.triplestoremessages.{SparqlAskRequest, SparqlAskResponse} +import org.knora.webapi.messages.store.triplestoremessages.{SparqlAskRequest, SparqlAskResponse, StringLiteralV2} import org.knora.webapi.messages.v1.responder.projectmessages.ProjectInfoV1 import org.knora.webapi.messages.v2.responder.KnoraContentV2 import org.knora.webapi.messages.v2.responder.standoffmessages._ @@ -3248,4 +3248,16 @@ class StringFormatter private (val maybeSettings: Option[KnoraSettingsImpl] = No throw BadRequestException(s"Internal ontology <$iri> cannot be served") } } + + def escapeSpecialCharactersInStringLiteral(stringLiteral: StringLiteralV2): StringLiteralV2 = { + val escapedValue = + toSparqlEncodedString(stringLiteral.value, throw BadRequestException(s"Invalid string: ${stringLiteral.value}")) + StringLiteralV2(value = escapedValue, language = stringLiteral.language) + } + + def unescapeSpecialCharactersInStringLiteral(stringLiteral: StringLiteralV2): StringLiteralV2 = { + val escapedValue = + fromSparqlEncodedString(stringLiteral.value) + StringLiteralV2(value = escapedValue, language = stringLiteral.language) + } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala index c0007c4c50..821961bc27 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala @@ -125,6 +125,21 @@ case class CreateNodeApiRequestADM(id: Option[IRI] = None, } def toJsValue: JsValue = createListNodeApiRequestADMFormat.write(this) + // Escapes special characters within strings + def escape: CreateNodeApiRequestADM = { + val escapedlabels: Seq[StringLiteralV2] = labels.map { label => + stringFormatter.escapeSpecialCharactersInStringLiteral(label) + } + val escapedcomments = comments.map { comment => + stringFormatter.escapeSpecialCharactersInStringLiteral(comment) + } + val escapedName: Option[String] = name match { + case None => None + case Some(value: String) => + Some(stringFormatter.toSparqlEncodedString(value, throw BadRequestException(s"Invalid string: $value"))) + } + copy(labels = escapedlabels, comments = escapedcomments, name = escapedName) + } } /** @@ -634,6 +649,24 @@ case class ListRootNodeInfoADM(id: IRI, ) } + def unescape: ListRootNodeInfoADM = { + val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val unescapesLabels = StringLiteralSequenceV2( + stringLiterals = + labels.stringLiterals.map(label => stringFormatter.unescapeSpecialCharactersInStringLiteral(label)) + ) + val unescapesComments = StringLiteralSequenceV2( + stringLiterals = + comments.stringLiterals.map(comment => stringFormatter.unescapeSpecialCharactersInStringLiteral(comment)) + ) + val unescapedName: Option[String] = name match { + case None => None + case Some(value) => Some(stringFormatter.fromSparqlEncodedString(value)) + } + copy(name = unescapedName, labels = unescapesLabels, comments = unescapesComments) + } + /** * Gets the label in the user's preferred language. * @@ -682,6 +715,24 @@ case class ListChildNodeInfoADM(id: IRI, ) } + def unescape: ListChildNodeInfoADM = { + val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val unescapesLabels = StringLiteralSequenceV2( + stringLiterals = + labels.stringLiterals.map(label => stringFormatter.unescapeSpecialCharactersInStringLiteral(label)) + ) + val unescapesComments = StringLiteralSequenceV2( + stringLiterals = + comments.stringLiterals.map(comment => stringFormatter.unescapeSpecialCharactersInStringLiteral(comment)) + ) + val unescapedName: Option[String] = name match { + case None => None + case Some(value) => Some(stringFormatter.fromSparqlEncodedString(value)) + } + copy(name = unescapedName, labels = unescapesLabels, comments = unescapesComments) + } + /** * Gets the label in the user's preferred language. * @@ -790,6 +841,24 @@ case class ListRootNodeADM(id: IRI, ) } + def unescape: ListRootNodeADM = { + val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val unescapesLabels = StringLiteralSequenceV2( + stringLiterals = + labels.stringLiterals.map(label => stringFormatter.unescapeSpecialCharactersInStringLiteral(label)) + ) + val unescapesComments = StringLiteralSequenceV2( + stringLiterals = + comments.stringLiterals.map(comment => stringFormatter.unescapeSpecialCharactersInStringLiteral(comment)) + ) + val unescapedName: Option[String] = name match { + case None => None + case Some(value) => Some(stringFormatter.fromSparqlEncodedString(value)) + } + copy(name = unescapedName, labels = unescapesLabels, comments = unescapesComments) + } + /** * Gets the label in the user's preferred language. * @@ -850,6 +919,24 @@ case class ListChildNodeADM(id: IRI, ) } + def unescape: ListChildNodeADM = { + val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val unescapesLabels = StringLiteralSequenceV2( + stringLiterals = + labels.stringLiterals.map(label => stringFormatter.unescapeSpecialCharactersInStringLiteral(label)) + ) + val unescapesComments = StringLiteralSequenceV2( + stringLiterals = + comments.stringLiterals.map(comment => stringFormatter.unescapeSpecialCharactersInStringLiteral(comment)) + ) + val unescapedName: Option[String] = name match { + case None => None + case Some(value) => Some(stringFormatter.fromSparqlEncodedString(value)) + } + copy(name = unescapedName, labels = unescapesLabels, comments = unescapesComments) + } + /** * Gets the label in the user's preferred language. * diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponderADM.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponderADM.scala index abcef8929c..93a1a0ef47 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponderADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/ListsResponderADM.scala @@ -154,7 +154,7 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde name = name, labels = StringLiteralSequenceV2(labels.toVector.sortBy(_.language)), comments = StringLiteralSequenceV2(comments.toVector.sortBy(_.language)) - ) + ).unescape } // _ = log.debug("listsGetAdminRequest - items: {}", items) @@ -200,7 +200,7 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde rootNodeInfo = maybeRootNodeInfo match { case Some(info: ListRootNodeInfoADM) => info.asInstanceOf[ListRootNodeInfoADM] - case Some(info: ListChildNodeInfoADM) => + case Some(_: ListChildNodeInfoADM) => throw InconsistentRepositoryDataException( "A child node info was found, although we are expecting a root node info. Please report this as a possible bug.") case Some(_) | None => @@ -393,7 +393,7 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde .map(_.head.asInstanceOf[StringLiteralV2].value), labels = StringLiteralSequenceV2(labels.toVector.sortBy(_.language)), comments = StringLiteralSequenceV2(comments.toVector.sortBy(_.language)) - ) + ).unescape } else { ListChildNodeInfoADM( id = nodeIri.toString, @@ -408,7 +408,7 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde hasRootNode = hasRootNodeOption.getOrElse( throw InconsistentRepositoryDataException( s"Required hasRootNode property missing for list node $nodeIri.")) - ) + ).unescape } } Some(nodeInfo) @@ -663,7 +663,7 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde children = children.map(_.sorted), position = position, hasRootNode = hasRootNode - ) + ).unescape } for { @@ -891,8 +891,10 @@ class ListsResponderADM(responderData: ResponderData) extends Responder(responde /* verify that the list node name is unique for the project */ projectUniqueNodeName <- listNodeNameIsProjectUnique(createNodeRequest.projectIri, createNodeRequest.name) _ = if (!projectUniqueNodeName) { + val escapedName = createNodeRequest.name.get + val unescapedName = stringFormatter.fromSparqlEncodedString(escapedName) throw BadRequestException( - s"The node name ${createNodeRequest.name.get} is already used by a list inside the project ${createNodeRequest.projectIri}.") + s"The node name ${unescapedName} is already used by a list inside the project ${createNodeRequest.projectIri}.") } // calculate the data named graph diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/NewListsRouteADMFeature.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/NewListsRouteADMFeature.scala index 2767bf8ff4..ee69811032 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/NewListsRouteADMFeature.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/NewListsRouteADMFeature.scala @@ -142,7 +142,7 @@ class NewListsRouteADMFeature(routeData: KnoraRouteData) createRequest = if (apiRequest.parentNodeIri.isEmpty) { // No, create a new list with given information of its root node. ListCreateRequestADM( - createRootNode = apiRequest, + createRootNode = apiRequest.escape, featureFactoryConfig = featureFactoryConfig, requestingUser = requestingUser, apiRequestID = UUID.randomUUID() @@ -150,7 +150,7 @@ class NewListsRouteADMFeature(routeData: KnoraRouteData) } else { // Yes, create a new child and attach it to the parent node. ListChildNodeCreateRequestADM( - createChildNodeRequest = apiRequest, + createChildNodeRequest = apiRequest.escape, featureFactoryConfig = featureFactoryConfig, requestingUser = requestingUser, apiRequestID = UUID.randomUUID() diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/OldListsRouteADMFeature.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/OldListsRouteADMFeature.scala index 14267cc8ae..f458d1cdf7 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/OldListsRouteADMFeature.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/lists/OldListsRouteADMFeature.scala @@ -128,7 +128,7 @@ class OldListsRouteADMFeature(routeData: KnoraRouteData) ) } yield ListCreateRequestADM( - createRootNode = apiRequest, + createRootNode = apiRequest.escape, featureFactoryConfig = featureFactoryConfig, requestingUser = requestingUser, apiRequestID = UUID.randomUUID() @@ -266,7 +266,7 @@ class OldListsRouteADMFeature(routeData: KnoraRouteData) ) } yield ListChildNodeCreateRequestADM( - createChildNodeRequest = apiRequest, + createChildNodeRequest = apiRequest.escape, featureFactoryConfig = featureFactoryConfig, requestingUser = requestingUser, apiRequestID = UUID.randomUUID() diff --git a/webapi/src/test/scala/org/knora/webapi/responders/admin/ListsResponderADMSpec.scala b/webapi/src/test/scala/org/knora/webapi/responders/admin/ListsResponderADMSpec.scala index 6bd6cb05fc..19d3a340d8 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/admin/ListsResponderADMSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/admin/ListsResponderADMSpec.scala @@ -26,6 +26,7 @@ import akka.testkit._ import com.typesafe.config.{Config, ConfigFactory} import org.knora.webapi._ import org.knora.webapi.exceptions.{BadRequestException, DuplicateValueException, UpdateNotPerformedException} +import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.admin.responder.listsmessages._ import org.knora.webapi.messages.store.triplestoremessages.{RdfDataObject, StringLiteralV2} import org.knora.webapi.sharedtestdata.SharedTestDataV1._ @@ -51,6 +52,7 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with // The default timeout for receiving reply messages from actors. implicit private val timeout = 5.seconds + private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance override lazy val rdfDataObjects = List( RdfDataObject(path = "test_data/demo_data/images-demo-data.ttl", name = "http://www.knora.org/data/00FF/images"), @@ -179,7 +181,7 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with name = Some("neuelistename"), labels = Seq(StringLiteralV2(value = "Neue Liste", language = Some("de"))), comments = Seq.empty[StringLiteralV2] - ), + ).escape, featureFactoryConfig = defaultFeatureFactoryConfig, requestingUser = SharedTestDataADM.imagesUser01, apiRequestID = UUID.randomUUID @@ -206,8 +208,46 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with newListIri.set(listInfo.id) } + "create a list with special characters in its labels" in { + val labelWithSpecialCharacter = "Neue \\\"Liste\\\"" + val commentWithSpecialCharacter = "Neue \\\"Kommentar\\\"" + val nameWithSpecialCharacter = "a new \\\"name\\\"" + responderManager ! ListCreateRequestADM( + createRootNode = CreateNodeApiRequestADM( + projectIri = IMAGES_PROJECT_IRI, + name = Some(nameWithSpecialCharacter), + labels = Seq(StringLiteralV2(value = labelWithSpecialCharacter, language = Some("de"))), + comments = Seq(StringLiteralV2(value = commentWithSpecialCharacter, language = Some("de"))), + ).escape, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = SharedTestDataADM.imagesUser01, + apiRequestID = UUID.randomUUID + ) + + val received: ListGetResponseADM = expectMsgType[ListGetResponseADM](timeout) + + val listInfo = received.list.listinfo + listInfo.projectIri should be(IMAGES_PROJECT_IRI) + + listInfo.name should be(Some(stringFormatter.fromSparqlEncodedString(nameWithSpecialCharacter))) + + val labels: Seq[StringLiteralV2] = listInfo.labels.stringLiterals + labels.size should be(1) + val givenLabel = labels.head + givenLabel.value shouldEqual stringFormatter.fromSparqlEncodedString(labelWithSpecialCharacter) + givenLabel.language shouldEqual Some("de") + + val comments = received.list.listinfo.comments.stringLiterals + val givenComment = comments.head + givenComment.language shouldEqual Some("de") + givenComment.value shouldEqual stringFormatter.fromSparqlEncodedString(commentWithSpecialCharacter) + + val children = received.list.children + children.size should be(0) + } + "update basic list information" in { - responderManager ! NodeInfoChangeRequestADM( + val changeNodeInfoRequest = NodeInfoChangeRequestADM( listIri = newListIri.get, changeNodeRequest = ChangeNodeInfoApiRequestADM( listIri = newListIri.get, @@ -216,18 +256,19 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with labels = Some( Seq( StringLiteralV2(value = "Neue geƤnderte Liste", language = Some("de")), - StringLiteralV2(value = "Changed list", language = Some("en")) + StringLiteralV2(value = "Changed List", language = Some("en")) )), comments = Some( Seq( StringLiteralV2(value = "Neuer Kommentar", language = Some("de")), - StringLiteralV2(value = "New comment", language = Some("en")) + StringLiteralV2(value = "New Comment", language = Some("en")) )) ), featureFactoryConfig = defaultFeatureFactoryConfig, requestingUser = SharedTestDataADM.imagesUser01, apiRequestID = UUID.randomUUID ) + responderManager ! changeNodeInfoRequest val received: RootNodeInfoGetResponseADM = expectMsgType[RootNodeInfoGetResponseADM](timeout) @@ -239,7 +280,7 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with labels.sorted should be( Seq( StringLiteralV2(value = "Neue geƤnderte Liste", language = Some("de")), - StringLiteralV2(value = "Changed list", language = Some("en")) + StringLiteralV2(value = "Changed List", language = Some("en")) ).sorted) val comments = listInfo.comments.stringLiterals @@ -247,7 +288,7 @@ class ListsResponderADMSpec extends CoreSpec(ListsResponderADMSpec.config) with comments.sorted should be( Seq( StringLiteralV2(value = "Neuer Kommentar", language = Some("de")), - StringLiteralV2(value = "New comment", language = Some("en")) + StringLiteralV2(value = "New Comment", language = Some("en")) ).sorted) } From c7e15e7054b09dfaeeb1bf0f4990b222afbeeed7 Mon Sep 17 00:00:00 2001 From: Sepideh Alassi Date: Thu, 22 Apr 2021 14:03:01 +0200 Subject: [PATCH 2/7] refactor (lists): add signature for escape and unescape methods --- .../listsmessages/ListsMessagesADM.scala | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala index 821961bc27..cc69c26982 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/listsmessages/ListsMessagesADM.scala @@ -125,7 +125,11 @@ case class CreateNodeApiRequestADM(id: Option[IRI] = None, } def toJsValue: JsValue = createListNodeApiRequestADMFormat.write(this) - // Escapes special characters within strings + + /** + * Escapes special characters within strings + * + */ def escape: CreateNodeApiRequestADM = { val escapedlabels: Seq[StringLiteralV2] = labels.map { label => stringFormatter.escapeSpecialCharactersInStringLiteral(label) @@ -649,6 +653,10 @@ case class ListRootNodeInfoADM(id: IRI, ) } + /** + * unescapes the special characters in labels, comments, and name for comparison in tests. + * + */ def unescape: ListRootNodeInfoADM = { val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance @@ -715,6 +723,10 @@ case class ListChildNodeInfoADM(id: IRI, ) } + /** + * unescapes the special characters in labels, comments, and name for comparison in tests. + * + */ def unescape: ListChildNodeInfoADM = { val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance @@ -841,6 +853,10 @@ case class ListRootNodeADM(id: IRI, ) } + /** + * unescapes the special characters in labels, comments, and name for comparison in tests. + * + */ def unescape: ListRootNodeADM = { val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance @@ -919,6 +935,10 @@ case class ListChildNodeADM(id: IRI, ) } + /** + * unescapes the special characters in labels, comments, and name for comparison in tests. + * + */ def unescape: ListChildNodeADM = { val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance From 6e0301ae96d688c770f587ef15c41651e1fa5ac4 Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Mon, 26 Apr 2021 11:30:59 +0200 Subject: [PATCH 3/7] feat(api-v2): Add route to change GUI order. --- .../ontologymessages/OntologyMessagesV2.scala | 54 +++++++ .../responders/v2/OntologyResponderV2.scala | 138 ++++++++++++++++++ .../webapi/routing/v2/OntologiesRouteV2.scala | 42 ++++++ 3 files changed, 234 insertions(+) diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala index 0ecb15bf45..0ce82a9c5c 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/OntologyMessagesV2.scala @@ -913,6 +913,60 @@ object ChangeClassLabelsOrCommentsRequestV2 extends KnoraJsonLDRequestReaderV2[C } } +case class ChangeGuiOrderRequestV2(classInfoContent: ClassInfoContentV2, + lastModificationDate: Instant, + apiRequestID: UUID, + featureFactoryConfig: FeatureFactoryConfig, + requestingUser: UserADM) + extends OntologiesResponderRequestV2 + +object ChangeGuiOrderRequestV2 extends KnoraJsonLDRequestReaderV2[ChangeGuiOrderRequestV2] { + override def fromJsonLD(jsonLDDocument: JsonLDDocument, + apiRequestID: UUID, + requestingUser: UserADM, + responderManager: ActorRef, + storeManager: ActorRef, + featureFactoryConfig: FeatureFactoryConfig, + settings: KnoraSettingsImpl, + log: LoggingAdapter)(implicit timeout: Timeout, + executionContext: ExecutionContext): Future[ChangeGuiOrderRequestV2] = { + Future { + fromJsonLDSync( + jsonLDDocument = jsonLDDocument, + apiRequestID = apiRequestID, + featureFactoryConfig = featureFactoryConfig, + requestingUser = requestingUser + ) + } + } + + private def fromJsonLDSync(jsonLDDocument: JsonLDDocument, + apiRequestID: UUID, + featureFactoryConfig: FeatureFactoryConfig, + requestingUser: UserADM): ChangeGuiOrderRequestV2 = { + // Get the class definition and the ontology's last modification date from the JSON-LD. + + val inputOntologiesV2 = InputOntologyV2.fromJsonLD(jsonLDDocument) + val classUpdateInfo = OntologyUpdateHelper.getClassDef(inputOntologiesV2) + val classInfoContent = classUpdateInfo.classInfoContent + val lastModificationDate = classUpdateInfo.lastModificationDate + + // The request must provide cardinalities. + + if (classInfoContent.directCardinalities.isEmpty) { + throw BadRequestException("No cardinalities specified") + } + + ChangeGuiOrderRequestV2( + classInfoContent = classInfoContent, + lastModificationDate = lastModificationDate, + apiRequestID = apiRequestID, + featureFactoryConfig = featureFactoryConfig, + requestingUser = requestingUser + ) + } +} + /** * Requests a change in the metadata of an ontology. A successful response will be a [[ReadOntologyMetadataV2]]. * diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index 0b52f815d4..c409816b31 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -145,6 +145,7 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon addCardinalitiesToClass(addCardinalitiesToClassRequest) case changeCardinalitiesRequest: ChangeCardinalitiesRequestV2 => changeClassCardinalities(changeCardinalitiesRequest) + case changeGuiOrderRequest: ChangeGuiOrderRequestV2 => changeGuiOrder(changeGuiOrderRequest) case deleteClassRequest: DeleteClassRequestV2 => deleteClass(deleteClassRequest) case createPropertyRequest: CreatePropertyRequestV2 => createProperty(createPropertyRequest) case changePropertyLabelsOrCommentsRequest: ChangePropertyLabelsOrCommentsRequestV2 => @@ -2941,6 +2942,143 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon } yield taskResult } + private def changeGuiOrder(changeGuiOrderRequest: ChangeGuiOrderRequestV2): Future[ReadOntologyV2] = { + def makeTaskFuture(internalClassIri: SmartIri, internalOntologyIri: SmartIri): Future[ReadOntologyV2] = { + for { + cacheData <- getCacheData + internalClassDef: ClassInfoContentV2 = changeGuiOrderRequest.classInfoContent.toOntologySchema(InternalSchema) + + // Check that the ontology exists and has not been updated by another user since the client last read it. + _ <- checkOntologyLastModificationDateBeforeUpdate( + internalOntologyIri = internalOntologyIri, + expectedLastModificationDate = changeGuiOrderRequest.lastModificationDate, + featureFactoryConfig = changeGuiOrderRequest.featureFactoryConfig + ) + + // Check that the class's rdf:type is owl:Class. + + rdfType: SmartIri = internalClassDef.requireIriObject(OntologyConstants.Rdf.Type.toSmartIri, + throw BadRequestException(s"No rdf:type specified")) + + _ = if (rdfType != OntologyConstants.Owl.Class.toSmartIri) { + throw BadRequestException(s"Invalid rdf:type for property: $rdfType") + } + + // Check that the class exists. + + ontology = cacheData.ontologies(internalOntologyIri) + + currentReadClassInfo: ReadClassInfoV2 = ontology.classes + .getOrElse( + internalClassIri, + throw BadRequestException(s"Class ${changeGuiOrderRequest.classInfoContent.classIri} does not exist")) + + // Make an updated class definition. + + newReadClassInfo = currentReadClassInfo.copy( + entityInfoContent = currentReadClassInfo.entityInfoContent.copy( + directCardinalities = currentReadClassInfo.entityInfoContent.directCardinalities.map { + case (propertyIri: SmartIri, cardinalityWithCurrentGuiOrder: KnoraCardinalityInfo) => + internalClassDef.directCardinalities.get(propertyIri) match { + case Some(cardinalityWithNewGuiOrder) => + propertyIri -> cardinalityWithCurrentGuiOrder.copy(guiOrder = cardinalityWithNewGuiOrder.guiOrder) + + case None => propertyIri -> cardinalityWithCurrentGuiOrder + } + } + ) + ) + + // Replace the cardinalities in the class definition in the triplestore. + + currentTime: Instant = Instant.now + + updateSparql = org.knora.webapi.messages.twirl.queries.sparql.v2.txt + .replaceClassCardinalities( + triplestore = settings.triplestoreType, + ontologyNamedGraphIri = internalOntologyIri, + ontologyIri = internalOntologyIri, + classIri = internalClassIri, + newCardinalities = newReadClassInfo.entityInfoContent.directCardinalities, + lastModificationDate = changeGuiOrderRequest.lastModificationDate, + currentTime = currentTime + ) + .toString() + + _ <- (storeManager ? SparqlUpdateRequest(updateSparql)).mapTo[SparqlUpdateResponse] + + // Check that the ontology's last modification date was updated. + + _ <- checkOntologyLastModificationDateAfterUpdate( + internalOntologyIri = internalOntologyIri, + expectedLastModificationDate = currentTime, + featureFactoryConfig = changeGuiOrderRequest.featureFactoryConfig + ) + + // Check that the data that was saved corresponds to the data that was submitted. + + loadedClassDef: ClassInfoContentV2 <- loadClassDefinition( + classIri = internalClassIri, + featureFactoryConfig = changeGuiOrderRequest.featureFactoryConfig + ) + + _ = if (loadedClassDef != newReadClassInfo.entityInfoContent) { + throw InconsistentRepositoryDataException( + s"Attempted to save class definition ${newReadClassInfo.entityInfoContent}, but $loadedClassDef was saved") + } + + // Update the cache. + + updatedOntology = ontology.copy( + ontologyMetadata = ontology.ontologyMetadata.copy( + lastModificationDate = Some(currentTime) + ), + classes = ontology.classes + (internalClassIri -> newReadClassInfo) + ) + + _ = storeCacheData( + cacheData.copy( + ontologies = cacheData.ontologies + (internalOntologyIri -> updatedOntology) + )) + + // Read the data back from the cache. + + response <- getClassDefinitionsFromOntologyV2( + classIris = Set(internalClassIri), + allLanguages = true, + requestingUser = changeGuiOrderRequest.requestingUser + ) + } yield response + } + + for { + requestingUser <- FastFuture.successful(changeGuiOrderRequest.requestingUser) + + externalClassIri = changeGuiOrderRequest.classInfoContent.classIri + externalOntologyIri = externalClassIri.getOntologyFromEntity + + _ <- checkOntologyAndEntityIrisForUpdate( + externalOntologyIri = externalOntologyIri, + externalEntityIri = externalClassIri, + requestingUser = requestingUser + ) + + internalClassIri = externalClassIri.toOntologySchema(InternalSchema) + internalOntologyIri = externalOntologyIri.toOntologySchema(InternalSchema) + + // Do the remaining pre-update checks and the update while holding a global ontology cache lock. + taskResult <- IriLocker.runWithIriLock( + apiRequestID = changeGuiOrderRequest.apiRequestID, + iri = ONTOLOGY_CACHE_LOCK_IRI, + task = () => + makeTaskFuture( + internalClassIri = internalClassIri, + internalOntologyIri = internalOntologyIri + ) + ) + } yield taskResult + } + /** * Replaces a class's cardinalities with new ones. * diff --git a/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala b/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala index 270cc165e3..20e942722c 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/v2/OntologiesRouteV2.scala @@ -61,6 +61,7 @@ class OntologiesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) updateClass(featureFactoryConfig) ~ addCardinalities(featureFactoryConfig) ~ replaceCardinalities(featureFactoryConfig) ~ + changeGuiOrder(featureFactoryConfig) ~ getClasses(featureFactoryConfig) ~ deleteClass(featureFactoryConfig) ~ deleteOntologyComment(featureFactoryConfig) ~ @@ -440,6 +441,47 @@ class OntologiesRouteV2(routeData: KnoraRouteData) extends KnoraRoute(routeData) } } + private def changeGuiOrder(featureFactoryConfig: FeatureFactoryConfig): Route = + path(OntologiesBasePath / "guiorder") { + put { + // Change a class's cardinalities. + entity(as[String]) { jsonRequest => requestContext => + { + val requestMessageFuture: Future[ChangeGuiOrderRequestV2] = for { + requestingUser <- getUserADM( + requestContext = requestContext, + featureFactoryConfig = featureFactoryConfig + ) + + requestDoc: JsonLDDocument = JsonLDUtil.parseJsonLD(jsonRequest) + + requestMessage: ChangeGuiOrderRequestV2 <- ChangeGuiOrderRequestV2.fromJsonLD( + jsonLDDocument = requestDoc, + apiRequestID = UUID.randomUUID, + requestingUser = requestingUser, + responderManager = responderManager, + storeManager = storeManager, + featureFactoryConfig = featureFactoryConfig, + settings = settings, + log = log + ) + } yield requestMessage + + RouteUtilV2.runRdfRouteWithFuture( + requestMessageF = requestMessageFuture, + requestContext = requestContext, + featureFactoryConfig = featureFactoryConfig, + settings = settings, + responderManager = responderManager, + log = log, + targetSchema = ApiV2Complex, + schemaOptions = RouteUtilV2.getSchemaOptions(requestContext) + ) + } + } + } + } + private def getClasses(featureFactoryConfig: FeatureFactoryConfig): Route = path(OntologiesBasePath / "classes" / Segments) { externalResourceClassIris: List[IRI] => get { requestContext => From 4206c4cae8c951dccbabb0a8bda8a980b006047f Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Mon, 26 Apr 2021 15:29:03 +0200 Subject: [PATCH 4/7] test(api-v2): Add tests. --- .../responders/v2/OntologyResponderV2.scala | 11 ++++ .../webapi/e2e/v2/OntologyV2R2RSpec.scala | 64 +++++++++++++++++++ .../v2/OntologyResponderV2Spec.scala | 64 ++++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index c409816b31..38b942b45f 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -2973,6 +2973,17 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon internalClassIri, throw BadRequestException(s"Class ${changeGuiOrderRequest.classInfoContent.classIri} does not exist")) + // Check that the properties submitted already have cardinalities. + + wrongProperties: Set[SmartIri] = internalClassDef.directCardinalities.keySet -- currentReadClassInfo.entityInfoContent.directCardinalities.keySet + + _ = if (wrongProperties.nonEmpty) { + throw BadRequestException( + s"One or more submitted properties do not have cardinalities in class ${changeGuiOrderRequest.classInfoContent.classIri}: ${wrongProperties + .map(_.toOntologySchema(ApiV2Complex)) + .mkString(", ")}") + } + // Make an updated class definition. newReadClassInfo = currentReadClassInfo.copy( diff --git a/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala b/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala index 8b126cbb44..5983058e42 100644 --- a/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/e2e/v2/OntologyV2R2RSpec.scala @@ -1631,6 +1631,70 @@ class OntologyV2R2RSpec extends R2RSpec { } } + "change the GUI order of the cardinality on anything:hasNothingness in the class anything:Nothing" in { + val params = + s"""{ + | "@id" : "${SharedOntologyTestDataADM.ANYTHING_ONTOLOGY_IRI_LocalHost}", + | "@type" : "owl:Ontology", + | "knora-api:lastModificationDate" : { + | "@type" : "xsd:dateTimeStamp", + | "@value" : "$anythingLastModDate" + | }, + | "@graph" : [ { + | "@id" : "anything:Nothing", + | "@type" : "owl:Class", + | "rdfs:subClassOf" : { + | "@type": "owl:Restriction", + | "owl:maxCardinality": 1, + | "owl:onProperty": { + | "@id" : "anything:hasNothingness" + | }, + | "salsah-gui:guiOrder": 2 + | } + | } ], + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "salsah-gui" : "http://api.knora.org/ontology/salsah-gui/v2#", + | "owl" : "http://www.w3.org/2002/07/owl#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#", + | "anything" : "${SharedOntologyTestDataADM.ANYTHING_ONTOLOGY_IRI_LocalHost}#" + | } + |}""".stripMargin + + clientTestDataCollector.addFile( + TestDataFileContent( + filePath = TestDataFilePath( + directoryPath = clientTestDataPath, + filename = "change-gui-order-request", + fileExtension = "json" + ), + text = params + ) + ) + + // Convert the submitted JSON-LD to an InputOntologyV2, without SPARQL-escaping, so we can compare it to the response. + val paramsAsInput: InputOntologyV2 = InputOntologyV2.fromJsonLD(JsonLDUtil.parseJsonLD(params)).unescape + + Put("/v2/ontologies/guiorder", HttpEntity(RdfMediaTypes.`application/ld+json`, params)) ~> addCredentials( + BasicHttpCredentials(anythingUsername, password)) ~> ontologiesPath ~> check { + assert(status == StatusCodes.OK, response.toString) + val responseJsonDoc = responseToJsonLDDocument(response) + + // Convert the response to an InputOntologyV2 and compare the relevant part of it to the request. + val responseAsInput: InputOntologyV2 = + InputOntologyV2.fromJsonLD(responseJsonDoc, parsingMode = TestResponseParsingModeV2).unescape + responseAsInput.classes.head._2.directCardinalities should ===( + paramsAsInput.classes.head._2.directCardinalities) + + // Check that the ontology's last modification date was updated. + val newAnythingLastModDate = responseAsInput.ontologyMetadata.lastModificationDate.get + assert(newAnythingLastModDate.isAfter(anythingLastModDate)) + anythingLastModDate = newAnythingLastModDate + } + } + "create a property anything:hasEmptiness with knora-api:subjectType anything:Nothing" in { val params = s"""{ diff --git a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala index f2d84c1847..63255372e6 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala @@ -2296,7 +2296,7 @@ class OntologyResponderV2Spec extends CoreSpec() with ImplicitSender { } } - "create a class anything:CardinalityThing cardinalities on anything:hasInterestingThing and anything:hasInterestingThingValue" in { + "create a class anything:CardinalityThing with cardinalities on anything:hasInterestingThing and anything:hasInterestingThingValue" in { val classIri = AnythingOntologyIri.makeEntityIri("CardinalityThing") val classInfoContent = ClassInfoContentV2( @@ -3897,6 +3897,68 @@ class OntologyResponderV2Spec extends CoreSpec() with ImplicitSender { } + "change the GUI order of the cardinalities of the class anything:Nothing" in { + val classIri = AnythingOntologyIri.makeEntityIri("Nothing") + + val classInfoContent = ClassInfoContentV2( + classIri = classIri, + predicates = Map( + OntologyConstants.Rdf.Type.toSmartIri -> PredicateInfoV2( + predicateIri = OntologyConstants.Rdf.Type.toSmartIri, + objects = Seq(SmartIriLiteralV2(OntologyConstants.Owl.Class.toSmartIri)) + ) + ), + directCardinalities = Map( + AnythingOntologyIri.makeEntityIri("hasOtherNothing") -> KnoraCardinalityInfo(cardinality = + Cardinality.MayHaveOne, + guiOrder = Some(1)), + AnythingOntologyIri.makeEntityIri("hasNothingness") -> KnoraCardinalityInfo(cardinality = + Cardinality.MayHaveOne, + guiOrder = Some(2)), + AnythingOntologyIri.makeEntityIri("hasEmptiness") -> KnoraCardinalityInfo( + cardinality = Cardinality.MayHaveOne, + guiOrder = Some(3)) + ), + ontologySchema = ApiV2Complex + ) + + responderManager ! ChangeCardinalitiesRequestV2( + classInfoContent = classInfoContent, + lastModificationDate = anythingLastModDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = anythingAdminUser + ) + + val expectedCardinalities = Map( + AnythingOntologyIri.makeEntityIri("hasOtherNothing") -> KnoraCardinalityInfo(cardinality = + Cardinality.MayHaveOne, + guiOrder = Some(1)), + AnythingOntologyIri.makeEntityIri("hasOtherNothingValue") -> KnoraCardinalityInfo(cardinality = + Cardinality.MayHaveOne, + guiOrder = Some(1)), + AnythingOntologyIri.makeEntityIri("hasNothingness") -> KnoraCardinalityInfo( + cardinality = Cardinality.MayHaveOne, + guiOrder = Some(2)), + AnythingOntologyIri.makeEntityIri("hasEmptiness") -> KnoraCardinalityInfo(cardinality = Cardinality.MayHaveOne, + guiOrder = Some(3)) + ) + + expectMsgPF(timeout) { + case msg: ReadOntologyV2 => + val externalOntology = msg.toOntologySchema(ApiV2Complex) + assert(externalOntology.classes.size == 1) + val readClassInfo = externalOntology.classes(classIri) + readClassInfo.entityInfoContent.directCardinalities should ===(expectedCardinalities) + + val metadata = externalOntology.ontologyMetadata + val newAnythingLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date")) + assert(newAnythingLastModDate.isAfter(anythingLastModDate)) + anythingLastModDate = newAnythingLastModDate + } + } + "change the cardinalities of the class anything:Nothing, removing anything:hasOtherNothing and anything:hasNothingness and leaving anything:hasEmptiness" in { val classIri = AnythingOntologyIri.makeEntityIri("Nothing") From 4dd9377d21c38947a8303db46f404aa2dc45d4cb Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Mon, 26 Apr 2021 16:34:26 +0200 Subject: [PATCH 5/7] test(api-v2): Update expected client test data. --- webapi/scripts/expected-client-test-data.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/webapi/scripts/expected-client-test-data.txt b/webapi/scripts/expected-client-test-data.txt index cfa31871f6..537c18dee0 100644 --- a/webapi/scripts/expected-client-test-data.txt +++ b/webapi/scripts/expected-client-test-data.txt @@ -213,6 +213,7 @@ test-data/v2/ontologies/minimal-ontology.json test-data/v2/ontologies/remove-class-cardinalities-request.json test-data/v2/ontologies/remove-property-cardinality-request.json test-data/v2/ontologies/replace-class-cardinalities-request.json +test-data/v2/ontologies/change-gui-order-request.json test-data/v2/ontologies/update-ontology-metadata-request.json test-data/v2/resources/ test-data/v2/resources/create-resource-as-user.json From f8f62c55d89325a447cebd5c4cd02effb4c33179 Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Mon, 26 Apr 2021 17:43:21 +0200 Subject: [PATCH 6/7] test(api-v2): Update expected client test data. --- webapi/scripts/expected-client-test-data.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapi/scripts/expected-client-test-data.txt b/webapi/scripts/expected-client-test-data.txt index 537c18dee0..e4f7495817 100644 --- a/webapi/scripts/expected-client-test-data.txt +++ b/webapi/scripts/expected-client-test-data.txt @@ -182,6 +182,7 @@ test-data/v2/ontologies/all-ontology-metadata-response.json test-data/v2/ontologies/anything-ontology.json test-data/v2/ontologies/change-class-comment-request.json test-data/v2/ontologies/change-class-label-request.json +test-data/v2/ontologies/change-gui-order-request.json test-data/v2/ontologies/change-property-comment-request.json test-data/v2/ontologies/change-property-label-request.json test-data/v2/ontologies/create-class-with-cardinalities-request.json @@ -213,7 +214,6 @@ test-data/v2/ontologies/minimal-ontology.json test-data/v2/ontologies/remove-class-cardinalities-request.json test-data/v2/ontologies/remove-property-cardinality-request.json test-data/v2/ontologies/replace-class-cardinalities-request.json -test-data/v2/ontologies/change-gui-order-request.json test-data/v2/ontologies/update-ontology-metadata-request.json test-data/v2/resources/ test-data/v2/resources/create-resource-as-user.json From 617821d42c879d15a50495865603e6e69f3c8a94 Mon Sep 17 00:00:00 2001 From: Benjamin Geer Date: Tue, 27 Apr 2021 10:39:38 +0200 Subject: [PATCH 7/7] docs(api-v2): Update docs. --- docs/03-apis/api-v2/ontology-information.md | 66 ++++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/03-apis/api-v2/ontology-information.md b/docs/03-apis/api-v2/ontology-information.md index 3a89340767..05af7b328f 100644 --- a/docs/03-apis/api-v2/ontology-information.md +++ b/docs/03-apis/api-v2/ontology-information.md @@ -1066,7 +1066,7 @@ HTTP POST to http://host/v2/ontologies/classes "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:label" : { @@ -1119,7 +1119,7 @@ HTTP POST to http://host/v2/ontologies/classes "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:label" : { @@ -1185,7 +1185,7 @@ HTTP PUT to http://host/v2/ontologies/classes "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:label" : { @@ -1224,7 +1224,7 @@ HTTP PUT to http://host/v2/ontologies/classes "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:comment" : { @@ -1262,7 +1262,7 @@ HTTP POST to http://host/v2/ontologies/properties "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "PROPERTY_IRI" : { + { "@id" : "PROPERTY_IRI", "@type" : "owl:ObjectProperty", "knora-api:subjectType" : { @@ -1351,7 +1351,7 @@ HTTP PUT to http://host/v2/ontologies/properties "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "PROPERTY_IRI" : { + { "@id" : "PROPERTY_IRI", "@type" : "owl:ObjectProperty", "rdfs:label" : { @@ -1389,7 +1389,7 @@ HTTP PUT to http://host/v2/ontologies/properties "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "PROPERTY_IRI" : { + { "@id" : "PROPERTY_IRI", "@type" : "owl:ObjectProperty", "rdfs:comment" : { @@ -1428,7 +1428,7 @@ HTTP POST to http://host/v2/ontologies/cardinalities "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:subClassOf" : { @@ -1487,7 +1487,7 @@ HTTP PUT to http://host/v2/ontologies/cardinalities "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" }, "@graph" : [ { - "CLASS_IRI" : { + { "@id" : "CLASS_IRI", "@type" : "owl:Class", "rdfs:subClassOf" : { @@ -1521,6 +1521,54 @@ on the corresponding link value property is automatically added (see A successful response will be a JSON-LD document providing the new class definition (but not any of the other entities in the ontology). +### Changing the GUI Order of Cardinalities + +To change the GUI order of one or more cardinalities in a class: + +``` +HTTP PUT to http://host/v2/ontologies/guiorder +``` + +This can be done even if the class is used in data. + +The request body includes the cardinalities whose GUI order should be changed, +using the predicate `salsah-gui:guiOrder`, whose object is an integer: + +```jsonld +{ + "@id" : "ONTOLOGY_IRI", + "@type" : "owl:Ontology", + "knora-api:lastModificationDate" : { + "@type" : "xsd:dateTimeStamp", + "@value" : "ONTOLOGY_LAST_MODIFICATION_DATE" + }, + "@graph" : [ { + "@id" : "CLASS_IRI", + "@type" : "owl:Class", + "rdfs:subClassOf" : { + "@type": "owl:Restriction", + "OWL_CARDINALITY_PREDICATE": "OWL_CARDINALITY_VALUE", + "owl:onProperty": { + "@id" : "PROPERTY_IRI" + }, + "salsah-gui:guiOrder": "GUI_ORDER_VALUE" + } + } ], + "@context" : { + "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + "salsah-gui" : "http://api.knora.org/ontology/salsah-gui/v2#", + "owl" : "http://www.w3.org/2002/07/owl#", + "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + "xsd" : "http://www.w3.org/2001/XMLSchema#", + } +} +``` + +Only the cardinalities whose GUI order is to be changed need to be included +in the request. The `OWL_CARDINALITY_PREDICATE` and `OWL_CARDINALITY_VALUE` +are ignored; only the `GUI_ORDER_VALUE` is changed. + ### Deleting a Property A property can be deleted only if no other ontology entity refers to it,