diff --git a/docs/src/paradox/00-release-notes/v2.x.x.md b/docs/src/paradox/00-release-notes/v2.x.x.md index 459a125d11..9a06751a04 100644 --- a/docs/src/paradox/00-release-notes/v2.x.x.md +++ b/docs/src/paradox/00-release-notes/v2.x.x.md @@ -31,14 +31,16 @@ description. - trouble with xml-checker and/or consistency-checker during bulk import (@github[#978](#978)) - ontology API error with link values (@github[#988](#988)) -## v2.0.1 (not release yet) +## v2.0.1 (not released yet) ### Bugfixes: - sipi container config / sipi not able to talk to knora (@github[#988](#994)) -## v2.1.0 (not release yet) +## v2.1.0 (not released yet) ### New features: -### Bugfixes: \ No newline at end of file +- Implement graph query in API v2 (@github[#1009](#1009)) + +### Bugfixes: diff --git a/docs/src/paradox/03-apis/api-v2/reading-and-searching-resources.md b/docs/src/paradox/03-apis/api-v2/reading-and-searching-resources.md index 337d04395f..6b2b579484 100644 --- a/docs/src/paradox/03-apis/api-v2/reading-and-searching-resources.md +++ b/docs/src/paradox/03-apis/api-v2/reading-and-searching-resources.md @@ -100,6 +100,80 @@ the path segment `resourcespreview`: HTTP GET to http://host/v2/resourcespreview/resourceIRI(/anotherResourceIri)* ``` +## Get a Graph of Resources + +Knora can return a graph of connections between resources, e.g. for generating +a network diagram. + +``` +HTTP GET to http://host/v2/graph/resourceIRI[depth=Integer] +[direction=outbound|inbound|both][excludeProperty=propertyIri] +``` + +The first parameter must be preceded by a question mark `?`, any +following parameter by an ampersand `&`. + +- `depth` must be at least 1. The maximum depth is an Knora configuration setting. + The default is 4. +- `direction` specifies the direction of the links to be queried, i.e. links to + and/or from the given resource. The default is `outbound`. +- `excludeProperty` is an optional link property to be excluded from the + results. + +To accommodate large graphs, the graph response format is very concise, and is therefore +simpler than the usual resources response format. Each resource represented only by its IRI, +class, and label. Direct links are shown instead of link values. For example: + +```jsonld +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + "@type" : "anything:Thing", + "rdfs:label" : "Sierra" + }, { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + "@type" : "anything:Thing", + "rdfs:label" : "Victor" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + }, + "rdfs:label" : "Tango" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + } ], + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} +``` + ## Search for Resources ### Search for a Resource by its `rdfs:label` @@ -122,8 +196,10 @@ specific. The first term should at least contain four characters. To make this kind of "search as you type" possible, a wildcard character is automatically added to the last search term. - HTTP GET to http://host/v2/searchbylabel/searchValue[limitToResourceClass=resourceClassIRI] - [limitToProject=projectIRI][offset=Integer] +``` +HTTP GET to http://host/v2/searchbylabel/searchValue[limitToResourceClass=resourceClassIRI] +[limitToProject=projectIRI][offset=Integer] +``` The first parameter must be preceded by a question mark `?`, any following parameter by an ampersand `&`. diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index b224f25abf..926b768b26 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -332,6 +332,11 @@ app { }, fulltext-search { search-value-min-length = 3 + }, + graph-route { + default-graph-depth = 4 + max-graph-depth = 10 + max-graph-breadth = 50 } } diff --git a/webapi/src/main/scala/org/knora/webapi/Settings.scala b/webapi/src/main/scala/org/knora/webapi/Settings.scala index 9f47dcba30..14a649f209 100644 --- a/webapi/src/main/scala/org/knora/webapi/Settings.scala +++ b/webapi/src/main/scala/org/knora/webapi/Settings.scala @@ -127,10 +127,13 @@ class SettingsImpl(config: Config) extends Extension { val defaultIconSizeDimX: Int = config.getInt("app.gui.default-icon-size.dimX") val defaultIconSizeDimY: Int = config.getInt("app.gui.default-icon-size.dimY") - val v2ResultsPerPage: Int = config.getInt("app.v2.resources-sequence.results-per-page") val searchValueMinLength: Int = config.getInt("app.v2.fulltext-search.search-value-min-length") + val defaultGraphDepth: Int = config.getInt("app.v2.graph-route.default-graph-depth") + val maxGraphDepth: Int = config.getInt("app.v2.graph-route.max-graph-depth") + val maxGraphBreadth: Int = config.getInt("app.v2.graph-route.max-graph-breadth") + val triplestoreType: String = config.getString("app.triplestore.dbtype") val triplestoreHost: String = config.getString("app.triplestore.host") diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2Simple.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2Simple.scala index 3b7ddd7f3a..84ef01b1a7 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2Simple.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2Simple.scala @@ -571,43 +571,6 @@ object KnoraApiV2Simple { ) } - /** - * Makes a [[ReadClassInfoV2]] representing an owl:Class. - * - * @param classIri the IRI of the class. - * @param subClassOf the set of direct superclasses of this class. - * @param predicates the predicates of the class. - * @param directCardinalities the direct cardinalities of the class. - * @param inheritedCardinalities the inherited cardinalities of the class. - * @return a [[ReadClassInfoV2]]. - */ - private def makeClass(classIri: IRI, - subClassOf: Set[IRI] = Set.empty[IRI], - predicates: Seq[PredicateInfoV2] = Seq.empty[PredicateInfoV2], - directCardinalities: Map[IRI, Cardinality.Value] = Map.empty[IRI, Cardinality.Value], - inheritedCardinalities: Map[SmartIri, KnoraCardinalityInfo] = Map.empty[SmartIri, KnoraCardinalityInfo]): ReadClassInfoV2 = { - - val rdfType = OntologyConstants.Rdf.Type.toSmartIri -> PredicateInfoV2( - predicateIri = OntologyConstants.Rdf.Type.toSmartIri, - objects = Seq(SmartIriLiteralV2(OntologyConstants.Owl.Class.toSmartIri)) - ) - - ReadClassInfoV2( - entityInfoContent = ClassInfoContentV2( - classIri = classIri.toSmartIri, - predicates = predicates.map { - pred => pred.predicateIri -> pred - }.toMap + rdfType, - directCardinalities = directCardinalities.map { - case (propertyIri, cardinality) => propertyIri.toSmartIri -> KnoraCardinalityInfo(cardinality) - }, - subClassOf = subClassOf.map(_.toSmartIri), - ontologySchema = ApiV2Simple - ), - inheritedCardinalities = inheritedCardinalities - ) - } - /** * Makes a [[ReadClassInfoV2]] representing an rdfs:Datatype. * diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2WithValueObjects.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2WithValueObjects.scala index 285397aad9..7165fc7c7e 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2WithValueObjects.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/ontologymessages/KnoraApiV2WithValueObjects.scala @@ -1577,49 +1577,4 @@ object KnoraApiV2WithValueObjects { isLinkValueProp = isLinkValueProp ) } - - /** - * Makes a [[ReadClassInfoV2]]. - * - * @param classIri the IRI of the class. - * @param subClassOf the set of direct superclasses of this class. - * @param predicates the predicates of the class. - * @param isResourceClass true if this is a subclass of `knora-api:Resource`. - * @param canBeInstantiated true if this is a Knora resource class that can be instantiated via the Knora API. - * @param directCardinalities the direct cardinalities of the class. - * @param inheritedCardinalities the inherited cardinalities of the class. - * @return a [[ReadClassInfoV2]]. - */ - private def makeClass(classIri: IRI, - subClassOf: Set[IRI] = Set.empty[IRI], - predicates: Seq[PredicateInfoV2] = Seq.empty[PredicateInfoV2], - isResourceClass: Boolean = false, - canBeInstantiated: Boolean = false, - isValueClass: Boolean = false, - directCardinalities: Map[IRI, Cardinality.Value] = Map.empty[IRI, Cardinality.Value], - inheritedCardinalities: Map[SmartIri, KnoraCardinalityInfo] = Map.empty[SmartIri, KnoraCardinalityInfo]): ReadClassInfoV2 = { - val rdfType = OntologyConstants.Rdf.Type.toSmartIri -> PredicateInfoV2( - predicateIri = OntologyConstants.Rdf.Type.toSmartIri, - objects = Seq(SmartIriLiteralV2(OntologyConstants.Owl.Class.toSmartIri)) - ) - - ReadClassInfoV2( - entityInfoContent = ClassInfoContentV2( - classIri = classIri.toSmartIri, - predicates = predicates.map { - pred => pred.predicateIri -> pred - }.toMap + rdfType, - directCardinalities = directCardinalities.map { - case (propertyIri, cardinality) => propertyIri.toSmartIri -> KnoraCardinalityInfo(cardinality) - }, - subClassOf = subClassOf.map(iri => iri.toSmartIri), - ontologySchema = ApiV2WithValueObjects - ), - inheritedCardinalities = inheritedCardinalities, - canBeInstantiated = canBeInstantiated, - isResourceClass = isResourceClass, - isValueClass = isValueClass - ) - } - } 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 a6927d64e6..1da117cc86 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 @@ -536,7 +536,6 @@ case class ReadResourcesSequenceV2(numberOfResources: Int, resources: Seq[ReadRe toOntologySchema(targetSchema).generateJsonLD(targetSchema, settings) } - /** * Checks that a [[ReadResourcesSequenceV2]] contains exactly one resource, and returns that resource. If the resource * is not present, or if it's `ForbiddenResource`, throws an exception. @@ -562,3 +561,143 @@ case class ReadResourcesSequenceV2(numberOfResources: Int, resources: Seq[ReadRe resourceInfo } } + +/** + * Requests a graph of resources that are reachable via links to or from a given resource. A successful response + * will be a [[GraphDataGetResponseV2]]. + * + * @param resourceIri the IRI of the initial resource. + * @param depth the maximum depth of the graph, counting from the initial resource. + * @param inbound `true` to query inbound links. + * @param outbound `true` to query outbound links. + * @param excludeProperty the IRI of a link property to exclude from the results. + * @param requestingUser the user making the request. + */ +case class GraphDataGetRequestV2(resourceIri: IRI, + depth: Int, + inbound: Boolean, + outbound: Boolean, + excludeProperty: Option[SmartIri], + requestingUser: UserADM) extends ResourcesResponderRequestV2 { + if (!(inbound || outbound)) { + throw BadRequestException("No link direction selected") + } +} + +/** + * Represents a node (i.e. a resource) in a resource graph. + * + * @param resourceIri the IRI of the resource. + * @param resourceLabel the label of the resource. + * @param resourceClassIri the IRI of the resource's OWL class. + */ +case class GraphNodeV2(resourceIri: IRI, resourceClassIri: SmartIri, resourceLabel: String) extends KnoraReadV2[GraphNodeV2] { + override def toOntologySchema(targetSchema: ApiV2Schema): GraphNodeV2 = { + copy(resourceClassIri = resourceClassIri.toOntologySchema(targetSchema)) + } +} + +/** + * Represents an edge (i.e. a link) in a resource graph. + * + * @param source the resource that is the source of the link. + * @param propertyIri the link property that links the source to the target. + * @param target the resource that is the target of the link. + */ +case class GraphEdgeV2(source: IRI, propertyIri: SmartIri, target: IRI) extends KnoraReadV2[GraphEdgeV2] { + override def toOntologySchema(targetSchema: ApiV2Schema): GraphEdgeV2 = { + copy(propertyIri = propertyIri.toOntologySchema(targetSchema)) + } +} + +/** + * Represents a graph of resources. + * + * @param nodes the nodes in the graph. + * @param edges the edges in the graph. + */ +case class GraphDataGetResponseV2(nodes: Seq[GraphNodeV2], edges: Seq[GraphEdgeV2], ontologySchema: OntologySchema) extends KnoraResponseV2 with KnoraReadV2[GraphDataGetResponseV2] { + private def generateJsonLD(targetSchema: ApiV2Schema, settings: SettingsImpl): JsonLDDocument = { + implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val sortedNodesInTargetSchema: Seq[GraphNodeV2] = nodes.map(_.toOntologySchema(targetSchema)).sortBy(_.resourceIri) + val edgesInTargetSchema: Seq[GraphEdgeV2] = edges.map(_.toOntologySchema(targetSchema)) + + // Make JSON-LD prefixes for the project-specific ontologies used in the response. + + val resourceOntologiesUsed: Set[SmartIri] = sortedNodesInTargetSchema.map(_.resourceClassIri.getOntologyFromEntity).toSet.filter(!_.isKnoraBuiltInDefinitionIri) + val propertyOntologiesUsed: Set[SmartIri] = edgesInTargetSchema.map(_.propertyIri.getOntologyFromEntity).toSet.filter(!_.isKnoraBuiltInDefinitionIri) + val projectSpecificOntologiesUsed = resourceOntologiesUsed ++ propertyOntologiesUsed + + // Make the knora-api prefix for the target schema. + + val knoraApiPrefixExpansion = targetSchema match { + case ApiV2Simple => OntologyConstants.KnoraApiV2Simple.KnoraApiV2PrefixExpansion + case ApiV2WithValueObjects => OntologyConstants.KnoraApiV2WithValueObjects.KnoraApiV2PrefixExpansion + } + + // Make the JSON-LD context. + + val context = JsonLDUtil.makeContext( + fixedPrefixes = Map( + "rdf" -> OntologyConstants.Rdf.RdfPrefixExpansion, + "rdfs" -> OntologyConstants.Rdfs.RdfsPrefixExpansion, + "xsd" -> OntologyConstants.Xsd.XsdPrefixExpansion, + OntologyConstants.KnoraApi.KnoraApiOntologyLabel -> knoraApiPrefixExpansion + ), + knoraOntologiesNeedingPrefixes = projectSpecificOntologiesUsed + ) + + // Group the edges by source IRI and add them to the nodes. + + val groupedEdges: Map[IRI, Seq[GraphEdgeV2]] = edgesInTargetSchema.groupBy(_.source) + + val nodesWithEdges: Seq[JsonLDObject] = sortedNodesInTargetSchema.map { + node: GraphNodeV2 => + // Convert the node to JSON-LD. + val jsonLDNodeMap = Map( + JsonLDConstants.ID -> JsonLDString(node.resourceIri), + JsonLDConstants.TYPE -> JsonLDString(node.resourceClassIri.toString), + OntologyConstants.Rdfs.Label -> JsonLDString(node.resourceLabel) + ) + + // Is this node the source of any edges? + groupedEdges.get(node.resourceIri) match { + case Some(nodeEdges: Seq[GraphEdgeV2]) => + // Yes. Convert them to JSON-LD and add them to the node. + + val nodeEdgesGroupedAndSortedByProperty: Vector[(SmartIri, Seq[GraphEdgeV2])] = nodeEdges.groupBy(_.propertyIri).toVector.sortBy(_._1) + + val jsonLDNodeEdges: Map[IRI, JsonLDArray] = nodeEdgesGroupedAndSortedByProperty.map { + case (propertyIri: SmartIri, propertyEdges: Seq[GraphEdgeV2]) => + val sortedPropertyEdges = propertyEdges.sortBy(_.target) + propertyIri.toString -> JsonLDArray(sortedPropertyEdges.map(propertyEdge => JsonLDUtil.iriToJsonLDObject(propertyEdge.target))) + }.toMap + + JsonLDObject(jsonLDNodeMap ++ jsonLDNodeEdges) + + case None => + // This node isn't the source of any edges. + JsonLDObject(jsonLDNodeMap) + } + } + + // Make the JSON-LD document. + + val body = JsonLDObject(Map(JsonLDConstants.GRAPH -> JsonLDArray(nodesWithEdges))) + + JsonLDDocument(body = body, context = context) + } + + override def toJsonLDDocument(targetSchema: ApiV2Schema, settings: SettingsImpl): JsonLDDocument = { + toOntologySchema(targetSchema).generateJsonLD(targetSchema, settings) + } + + override def toOntologySchema(targetSchema: ApiV2Schema): GraphDataGetResponseV2 = { + GraphDataGetResponseV2( + nodes = nodes.map(_.toOntologySchema(targetSchema)), + edges = edges.map(_.toOntologySchema(targetSchema)), + ontologySchema = targetSchema + ) + } +} 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 74bc70534c..0bb9901009 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 @@ -29,7 +29,7 @@ import akka.stream.ActorMaterializer import org.knora.webapi._ import org.knora.webapi.messages.admin.responder.permissionsmessages.{DefaultObjectAccessPermissionsStringForResourceClassGetADM, DefaultObjectAccessPermissionsStringResponseADM, ResourceCreateOperation} import org.knora.webapi.messages.admin.responder.usersmessages.UserADM -import org.knora.webapi.messages.store.triplestoremessages.{SparqlConstructRequest, SparqlConstructResponse, SparqlUpdateRequest, SparqlUpdateResponse} +import org.knora.webapi.messages.store.triplestoremessages._ import org.knora.webapi.messages.v2.responder.ontologymessages._ import org.knora.webapi.messages.v2.responder.resourcemessages._ import org.knora.webapi.messages.v2.responder.searchmessages.GravsearchRequestV2 @@ -67,6 +67,7 @@ class ResourcesResponderV2 extends ResponderWithStandoffV2 { case ResourcesPreviewGetRequestV2(resIris, requestingUser) => future2Message(sender(), getResourcePreview(resIris, requestingUser), log) case ResourceTEIGetRequestV2(resIri, textProperty, mappingIri, gravsearchTemplateIri, headerXSLTIri, requestingUser) => future2Message(sender(), getResourceAsTEI(resIri, textProperty, mappingIri, gravsearchTemplateIri, headerXSLTIri, requestingUser), log) case createResourceRequestV2: CreateResourceRequestV2 => future2Message(sender(), createResourceV2(createResourceRequestV2), log) + case graphDataGetRequest: GraphDataGetRequestV2 => future2Message(sender(), getGraphDataResponseV2(graphDataGetRequest), log) case other => handleUnexpectedMessage(sender(), other, log, this.getClass.getName) } @@ -1067,5 +1068,307 @@ class ResourcesResponderV2 extends ResponderWithStandoffV2 { getResourcePreview(targetResourceIris.toSeq, requestingUser).map(_ => ()) } } + + /** + * Gets a graph of resources that are reachable via links to or from a given resource. + * + * @param graphDataGetRequest a [[GraphDataGetRequestV2]] specifying the characteristics of the graph. + * @return a [[GraphDataGetResponseV2]] representing the requested graph. + */ + private def getGraphDataResponseV2(graphDataGetRequest: GraphDataGetRequestV2): Future[GraphDataGetResponseV2] = { + val excludePropertyInternal = graphDataGetRequest.excludeProperty.map(_.toOntologySchema(InternalSchema)) + + /** + * The internal representation of a node returned by a SPARQL query generated by the `getGraphData` template. + * + * @param nodeIri the IRI of the node. + * @param nodeClass the IRI of the node's class. + * @param nodeLabel the node's label. + * @param nodeCreator the node's creator. + * @param nodeProject the node's project. + * @param nodePermissions the node's permissions. + */ + case class QueryResultNode(nodeIri: IRI, + nodeClass: SmartIri, + nodeLabel: String, + nodeCreator: IRI, + nodeProject: IRI, + nodePermissions: String) + + /** + * The internal representation of an edge returned by a SPARQL query generated by the `getGraphData` template. + * + * @param linkValueIri the IRI of the link value. + * @param sourceNodeIri the IRI of the source node. + * @param targetNodeIri the IRI of the target node. + * @param linkProp the IRI of the link property. + * @param linkValueCreator the link value's creator. + * @param sourceNodeProject the project of the source node. + * @param linkValuePermissions the link value's permissions. + */ + case class QueryResultEdge(linkValueIri: IRI, + sourceNodeIri: IRI, + targetNodeIri: IRI, + linkProp: SmartIri, + linkValueCreator: IRI, + sourceNodeProject: IRI, + linkValuePermissions: String) + + /** + * Represents results returned by a SPARQL query generated by the `getGraphData` template. + * + * @param nodes the nodes that were returned by the query. + * @param edges the edges that were returned by the query. + */ + case class GraphQueryResults(nodes: Set[QueryResultNode] = Set.empty[QueryResultNode], edges: Set[QueryResultEdge] = Set.empty[QueryResultEdge]) + + /** + * Recursively queries outbound or inbound links from/to a resource. + * + * @param startNode the node to use as the starting point of the query. The user is assumed to have permission + * to see this node. + * @param outbound `true` to get outbound links, `false` to get inbound links. + * @param depth the maximum depth of the query. + * @param traversedEdges edges that have already been traversed. + * @return a [[GraphQueryResults]]. + */ + def traverseGraph(startNode: QueryResultNode, outbound: Boolean, depth: Int, traversedEdges: Set[QueryResultEdge] = Set.empty[QueryResultEdge]): Future[GraphQueryResults] = { + if (depth < 1) Future.failed(AssertionException("Depth must be at least 1")) + + for { + // Get the direct links from/to the start node. + sparql <- Future(queries.sparql.v2.txt.getGraphData( + triplestore = settings.triplestoreType, + startNodeIri = startNode.nodeIri, + startNodeOnly = false, + maybeExcludeLinkProperty = excludePropertyInternal, + outbound = outbound, // true to query outbound edges, false to query inbound edges + limit = settings.maxGraphBreadth + ).toString()) + + // _ = println(sparql) + + response: SparqlSelectResponse <- (storeManager ? SparqlSelectRequest(sparql)).mapTo[SparqlSelectResponse] + rows: Seq[VariableResultsRow] = response.results.bindings + + // Did we get any results? + recursiveResults: GraphQueryResults <- if (rows.isEmpty) { + // No. Return nothing. + Future(GraphQueryResults()) + } else { + // Yes. Get the nodes from the query results. + val otherNodes: Seq[QueryResultNode] = rows.map { + row: VariableResultsRow => + val rowMap: Map[String, String] = row.rowMap + + QueryResultNode( + nodeIri = rowMap("node"), + nodeClass = rowMap("nodeClass").toSmartIri, + nodeLabel = rowMap("nodeLabel"), + nodeCreator = rowMap("nodeCreator"), + nodeProject = rowMap("nodeProject"), + nodePermissions = rowMap("nodePermissions") + ) + }.filter { + node: QueryResultNode => + // Filter out the nodes that the user doesn't have permission to see. + PermissionUtilADM.getUserPermissionADM( + entityIri = node.nodeIri, + entityCreator = node.nodeCreator, + entityProject = node.nodeProject, + entityPermissionLiteral = node.nodePermissions, + requestingUser = graphDataGetRequest.requestingUser + ).nonEmpty + } + + // Collect the IRIs of the nodes that the user has permission to see, including the start node. + val visibleNodeIris: Set[IRI] = otherNodes.map(_.nodeIri).toSet + startNode.nodeIri + + // Get the edges from the query results. + val edges: Set[QueryResultEdge] = rows.map { + row: VariableResultsRow => + val rowMap: Map[String, String] = row.rowMap + val nodeIri: IRI = rowMap("node") + + // The SPARQL query takes a start node and returns the other node in the edge. + // + // If we're querying outbound edges, the start node is the source node, and the other + // node is the target node. + // + // If we're querying inbound edges, the start node is the target node, and the other + // node is the source node. + + QueryResultEdge( + linkValueIri = rowMap("linkValue"), + sourceNodeIri = if (outbound) startNode.nodeIri else nodeIri, + targetNodeIri = if (outbound) nodeIri else startNode.nodeIri, + linkProp = rowMap("linkProp").toSmartIri, + linkValueCreator = rowMap("linkValueCreator"), + sourceNodeProject = if (outbound) startNode.nodeProject else rowMap("nodeProject"), + linkValuePermissions = rowMap("linkValuePermissions") + ) + }.filter { + edge: QueryResultEdge => + // Filter out the edges that the user doesn't have permission to see. To see an edge, + // the user must have some permission on the link value and on the source and target + // nodes. + val hasPermission: Boolean = visibleNodeIris.contains(edge.sourceNodeIri) && visibleNodeIris.contains(edge.targetNodeIri) && + PermissionUtilADM.getUserPermissionADM( + entityIri = edge.linkValueIri, + entityCreator = edge.linkValueCreator, + entityProject = edge.sourceNodeProject, + entityPermissionLiteral = edge.linkValuePermissions, + requestingUser = graphDataGetRequest.requestingUser + ).nonEmpty + + // Filter out edges we've already traversed. + val isRedundant: Boolean = traversedEdges.contains(edge) + // if (isRedundant) println(s"filtering out edge from ${edge.sourceNodeIri} to ${edge.targetNodeIri}") + + hasPermission && !isRedundant + }.toSet + + // Include only nodes that are reachable via edges that we're going to traverse (i.e. the user + // has permission to see those edges, and we haven't already traversed them). + val visibleNodeIrisFromEdges: Set[IRI] = edges.map(_.sourceNodeIri) ++ edges.map(_.targetNodeIri) + val filteredOtherNodes: Seq[QueryResultNode] = otherNodes.filter(node => visibleNodeIrisFromEdges.contains(node.nodeIri)) + + // Make a GraphQueryResults containing the resulting nodes and edges, including the start + // node. + val results = GraphQueryResults(nodes = filteredOtherNodes.toSet + startNode, edges = edges) + + // Have we reached the maximum depth? + if (depth == 1) { + // Yes. Just return the results we have. + Future(results) + } else { + // No. Recursively get results for each of the nodes we found. + + val traversedEdgesForRecursion: Set[QueryResultEdge] = traversedEdges ++ edges + + val lowerResultFutures: Seq[Future[GraphQueryResults]] = filteredOtherNodes.map { + node => + traverseGraph( + startNode = node, + outbound = outbound, + depth = depth - 1, + traversedEdges = traversedEdgesForRecursion + ) + } + + val lowerResultsFuture: Future[Seq[GraphQueryResults]] = Future.sequence(lowerResultFutures) + + // Return those results plus the ones we found. + + lowerResultsFuture.map { + lowerResultsSeq: Seq[GraphQueryResults] => + lowerResultsSeq.foldLeft(results) { + case (acc: GraphQueryResults, lowerResults: GraphQueryResults) => + GraphQueryResults( + nodes = acc.nodes ++ lowerResults.nodes, + edges = acc.edges ++ lowerResults.edges + ) + } + } + } + } + } yield recursiveResults + } + + for { + // Get the start node. + sparql <- Future(queries.sparql.v2.txt.getGraphData( + triplestore = settings.triplestoreType, + startNodeIri = graphDataGetRequest.resourceIri, + maybeExcludeLinkProperty = excludePropertyInternal, + startNodeOnly = true, + outbound = true, + limit = settings.maxGraphBreadth + ).toString()) + + // _ = println(sparql) + + response: SparqlSelectResponse <- (storeManager ? SparqlSelectRequest(sparql)).mapTo[SparqlSelectResponse] + rows: Seq[VariableResultsRow] = response.results.bindings + + _ = if (rows.isEmpty) { + throw NotFoundException(s"Resource <${graphDataGetRequest.resourceIri}> not found (it may have been deleted)") + } + + firstRowMap: Map[String, String] = rows.head.rowMap + + startNode: QueryResultNode = QueryResultNode( + nodeIri = firstRowMap("node"), + nodeClass = firstRowMap("nodeClass").toSmartIri, + nodeLabel = firstRowMap("nodeLabel"), + nodeCreator = firstRowMap("nodeCreator"), + nodeProject = firstRowMap("nodeProject"), + nodePermissions = firstRowMap("nodePermissions") + ) + + // Make sure the user has permission to see the start node. + _ = if (PermissionUtilADM.getUserPermissionADM( + entityIri = startNode.nodeIri, + entityCreator = startNode.nodeCreator, + entityProject = startNode.nodeProject, + entityPermissionLiteral = startNode.nodePermissions, + requestingUser = graphDataGetRequest.requestingUser + ).isEmpty) { + throw ForbiddenException(s"User ${graphDataGetRequest.requestingUser.email} does not have permission to view resource <${graphDataGetRequest.resourceIri}>") + } + + // Recursively get the graph containing outbound links. + outboundQueryResults: GraphQueryResults <- if (graphDataGetRequest.outbound) { + traverseGraph( + startNode = startNode, + outbound = true, + depth = graphDataGetRequest.depth + ) + } else { + FastFuture.successful(GraphQueryResults()) + } + + // Recursively get the graph containing inbound links. + inboundQueryResults: GraphQueryResults <- if (graphDataGetRequest.inbound) { + traverseGraph( + startNode = startNode, + outbound = false, + depth = graphDataGetRequest.depth + ) + } else { + FastFuture.successful(GraphQueryResults()) + } + + // Combine the outbound and inbound graphs into a single graph. + nodes: Set[QueryResultNode] = outboundQueryResults.nodes ++ inboundQueryResults.nodes + startNode + edges: Set[QueryResultEdge] = outboundQueryResults.edges ++ inboundQueryResults.edges + + // Convert each node to a GraphNodeV2 for the API response message. + resultNodes: Vector[GraphNodeV2] = nodes.map { + node: QueryResultNode => + GraphNodeV2( + resourceIri = node.nodeIri, + resourceClassIri = node.nodeClass, + resourceLabel = node.nodeLabel, + ) + }.toVector + + // Convert each edge to a GraphEdgeV2 for the API response message. + resultEdges: Vector[GraphEdgeV2] = edges.map { + edge: QueryResultEdge => + GraphEdgeV2( + source = edge.sourceNodeIri, + propertyIri = edge.linkProp, + target = edge.targetNodeIri, + ) + }.toVector + + } yield GraphDataGetResponseV2( + nodes = resultNodes, + edges = resultEdges, + ontologySchema = InternalSchema + ) + } + } 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 cca28185e4..58131afbd3 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 @@ -27,7 +27,7 @@ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.util.Timeout import org.knora.webapi._ -import org.knora.webapi.messages.v2.responder.resourcemessages.{CreateResourceRequestV2, ResourceTEIGetRequestV2, ResourcesGetRequestV2, ResourcesPreviewGetRequestV2} +import org.knora.webapi.messages.v2.responder.resourcemessages._ import org.knora.webapi.responders.RESPONDER_MANAGER_ACTOR_PATH import org.knora.webapi.routing.{Authenticator, RouteUtilV2} import org.knora.webapi.store.STORE_MANAGER_ACTOR_PATH @@ -45,6 +45,12 @@ object ResourcesRouteV2 extends Authenticator { private val Mapping_Iri = "mappingIri" private val GravsearchTemplate_Iri = "gravsearchTemplateIri" private val TEIHeader_XSLT_IRI = "teiHeaderXSLTIri" + private val Depth = "depth" + private val ExcludeProperty = "excludeProperty" + private val Direction = "direction" + private val Inbound = "inbound" + private val Outbound = "outbound" + private val Both = "both" /** * Gets the Iri of the property that represents the text of the resource. @@ -58,10 +64,10 @@ object ResourcesRouteV2 extends Authenticator { textProperty match { case Some(textPropIriStr: String) => - val externalResourceClassIri = textPropIriStr.toSmartIriWithErr(throw BadRequestException(s"Invalid property IRI: $textPropIriStr")) + val externalResourceClassIri = textPropIriStr.toSmartIriWithErr(throw BadRequestException(s"Invalid property IRI: <$textPropIriStr>")) if (!externalResourceClassIri.isKnoraApiV2EntityIri) { - throw BadRequestException(s"$textPropIriStr is not a valid knora-api property IRI") + throw BadRequestException(s"<$textPropIriStr> is not a valid knora-api property IRI") } externalResourceClassIri.toOntologySchema(InternalSchema) @@ -82,7 +88,7 @@ object ResourcesRouteV2 extends Authenticator { mappingIriStr match { case Some(mapping: String) => - Some(stringFormatter.validateAndEscapeIri(mapping, throw BadRequestException(s"Invalid mapping IRI: '$mapping'"))) + Some(stringFormatter.validateAndEscapeIri(mapping, throw BadRequestException(s"Invalid mapping IRI: <$mapping>"))) case None => None } @@ -100,7 +106,7 @@ object ResourcesRouteV2 extends Authenticator { gravsearchTemplateIriStr match { case Some(gravsearch: String) => - Some(stringFormatter.validateAndEscapeIri(gravsearch, throw BadRequestException(s"Invalid template IRI: '$gravsearch'"))) + Some(stringFormatter.validateAndEscapeIri(gravsearch, throw BadRequestException(s"Invalid template IRI: <$gravsearch>"))) case None => None } @@ -118,7 +124,7 @@ object ResourcesRouteV2 extends Authenticator { headerXSLTIriStr match { case Some(xslt: String) => - Some(stringFormatter.validateAndEscapeIri(xslt, throw BadRequestException(s"Invalid XSLT IRI: '$xslt'"))) + Some(stringFormatter.validateAndEscapeIri(xslt, throw BadRequestException(s"Invalid XSLT IRI: <$xslt>"))) case None => None } @@ -170,7 +176,7 @@ object ResourcesRouteV2 extends Authenticator { val resourceIris: Seq[IRI] = resIris.map { resIri: String => - stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: '$resIri'")) + stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: <$resIri>")) } val requestMessage: Future[ResourcesGetRequestV2] = for { @@ -194,7 +200,7 @@ object ResourcesRouteV2 extends Authenticator { val resourceIris: Seq[IRI] = resIris.map { resIri: String => - stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: '$resIri'")) + stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: <$resIri>")) } val requestMessage: Future[ResourcesPreviewGetRequestV2] = for { @@ -216,7 +222,7 @@ object ResourcesRouteV2 extends Authenticator { get { requestContext => { - val resourceIri = stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: '$resIri'")) + val resourceIri: IRI = stringFormatter.validateAndEscapeIri(resIri, throw BadRequestException(s"Invalid resource IRI: <$resIri>")) val params: Map[String, String] = requestContext.request.uri.query().toMap @@ -251,9 +257,52 @@ object ResourcesRouteV2 extends Authenticator { } } - } + } ~ path("v2" / "graph" / Segment) { resIriStr: String => + get { + requestContext => { + val resourceIri: IRI = stringFormatter.validateAndEscapeIri(resIriStr, throw BadRequestException(s"Invalid resource IRI: <$resIriStr>")) + val params: Map[String, String] = requestContext.request.uri.query().toMap + val depth: Int = params.get(Depth).map(_.toInt).getOrElse(settings.defaultGraphDepth) - } + if (depth < 1) { + throw BadRequestException(s"$Depth must be at least 1") + } + + if (depth > settings.maxGraphDepth) { + throw BadRequestException(s"$Depth cannot be greater than ${settings.maxGraphDepth}") + } + + val direction: String = params.getOrElse(Direction, Outbound) + val excludeProperty: Option[SmartIri] = params.get(ExcludeProperty).map(propIriStr => propIriStr.toSmartIriWithErr(throw BadRequestException(s"Invalid property IRI: <$propIriStr>"))) + val (inbound: Boolean, outbound: Boolean) = direction match { + case Inbound => (true, false) + case Outbound => (false, true) + case Both => (true, true) + case other => throw BadRequestException(s"Invalid direction: $other") + } + val requestMessage: Future[GraphDataGetRequestV2] = for { + requestingUser <- getUserADM(requestContext) + } yield GraphDataGetRequestV2( + resourceIri = resourceIri, + depth = depth, + inbound = inbound, + outbound = outbound, + excludeProperty = excludeProperty, + requestingUser = requestingUser + ) + + RouteUtilV2.runRdfRouteWithFuture( + requestMessage, + requestContext, + settings, + responderManager, + log, + RouteUtilV2.getOntologySchema(requestContext) + ) + } + } + } + } } \ No newline at end of file diff --git a/webapi/src/main/twirl/queries/sparql/v2/getGraphData.scala.txt b/webapi/src/main/twirl/queries/sparql/v2/getGraphData.scala.txt new file mode 100644 index 0000000000..0646670cda --- /dev/null +++ b/webapi/src/main/twirl/queries/sparql/v2/getGraphData.scala.txt @@ -0,0 +1,61 @@ +@* + * Copyright © 2015-2018 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._ +@import org.knora.webapi.util.SmartIri + +@** + * Gets the outbound or inbound links from/to a resource. This query is used recursively to get a graph of + * resources reachable from a given resource. + * + * If the triplestore type is GraphDB, this template delegates to getGraphDataGraphDB.sparql.txt, which is optimised + * for GraphDB. Otherwise, it delegates to getGraphDataStandard.sparql.txt. + * + * @param triplestore the name of the triplestore being used. + * @param startNodeIri the IRI of the resource to use as the starting point of the query. + * @param startNodeOnly if true, returns information only about the start node. + * @param maybeExcludeLinkProperty if provided, a link property that should be excluded from the results. + * @param outbound true to get outbound links, false to get inbound links. + * @param limit the maximum number of edges to return. + *@ +@(triplestore: String, + startNodeIri: IRI, + startNodeOnly: Boolean, + maybeExcludeLinkProperty: Option[SmartIri], + outbound: Boolean, + limit: Int) + +@if(triplestore.startsWith("graphdb")) { + @{ + queries.sparql.v2.txt.getGraphDataGraphDB(startNodeIri = startNodeIri, + startNodeOnly = startNodeOnly, + maybeExcludeLinkProperty = maybeExcludeLinkProperty, + outbound = outbound, + limit = limit) + } +} else { + @{ + queries.sparql.v2.txt.getGraphDataStandard(triplestore = triplestore, + startNodeIri = startNodeIri, + startNodeOnly = startNodeOnly, + maybeExcludeLinkProperty = maybeExcludeLinkProperty, + outbound = outbound, + limit = limit) + } +} diff --git a/webapi/src/main/twirl/queries/sparql/v2/getGraphDataGraphDB.scala.txt b/webapi/src/main/twirl/queries/sparql/v2/getGraphDataGraphDB.scala.txt new file mode 100644 index 0000000000..84ed7555f4 --- /dev/null +++ b/webapi/src/main/twirl/queries/sparql/v2/getGraphDataGraphDB.scala.txt @@ -0,0 +1,133 @@ +@* + * Copyright © 2015-2018 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._ +@import org.knora.webapi.util.SmartIri + +@** + * Gets the outbound or inbound links from/to a resource, using GraphDB. This query is used recursively to get a graph + * of resources reachable from a given resource. + * + * This template is used only by getGraphData.scala.txt. + * + * Since the triplestore type is GraphDB, we assume that inference is enabled, and we use it to optimise the generated + * SPARQL. Specifically, we use inference to find subproperties of knora-base:hasLinkTo and knora-base:isPartOf. + * This requires us to use GraphDB's GRAPH whenever we need to get explicit + * (non-inferred) statements. + * + * @param startNodeIri the IRI of the resource to use as the starting point of the query. + * @param startNodeOnly if true, returns information only about the start node. + * @param maybeExcludeLinkProperty if provided, a link property that should be excluded from the results. + * @param outbound true to get outbound links, false to get inbound links. + * @param limit the maximum number of edges to return. + *@ +@(startNodeIri: IRI, + startNodeOnly: Boolean, + maybeExcludeLinkProperty: Option[SmartIri], + outbound: Boolean, + limit: Int) + +PREFIX rdf: +PREFIX rdfs: +PREFIX knora-base: + +SELECT ?node ?nodeClass ?nodeLabel ?nodeCreator ?nodeProject ?nodePermissions + ?linkValue ?linkProp ?linkValueCreator ?linkValuePermissions +WHERE { + @if(startNodeOnly) { + BIND(IRI("@startNodeIri") AS ?node) . + + GRAPH { + ?node a ?nodeClass . + } + + ?node rdfs:label ?nodeLabel ; + knora-base:attachedToUser ?nodeCreator ; + knora-base:attachedToProject ?nodeProject ; + knora-base:isDeleted false ; + knora-base:hasPermissions ?nodePermissions . + } else { + BIND(IRI("@startNodeIri") AS ?startNode) . + + @if(outbound) { + ?startNode knora-base:hasLinkTo ?node . + + @maybeExcludeLinkProperty match { + case Some(excludeLinkProperty) => { + + FILTER NOT EXISTS { + ?startNode <@excludeLinkProperty> ?node . + } + + } + + case None => {} + } + + GRAPH { + ?startNode ?linkProp ?node . + } + + ?node knora-base:isDeleted false . + + ?linkValue a knora-base:LinkValue ; + rdf:subject ?startNode ; + rdf:predicate ?linkProp ; + rdf:object ?node . + } else { + ?node knora-base:hasLinkTo ?startNode . + + @maybeExcludeLinkProperty match { + case Some(excludeLinkProperty) => { + + FILTER NOT EXISTS { + ?node <@excludeLinkProperty> ?startNode . + } + + } + + case None => {} + } + + GRAPH { + ?node ?linkProp ?startNode . + } + + ?node knora-base:isDeleted false . + + ?linkValue a knora-base:LinkValue ; + rdf:subject ?node ; + rdf:predicate ?linkProp ; + rdf:object ?startNode . + } + + GRAPH { + ?node a ?nodeClass . + } + + ?node rdfs:label ?nodeLabel ; + knora-base:attachedToUser ?nodeCreator ; + knora-base:attachedToProject ?nodeProject ; + knora-base:hasPermissions ?nodePermissions . + + ?linkValue knora-base:attachedToUser ?linkValueCreator ; + knora-base:hasPermissions ?linkValuePermissions . + } +} +LIMIT @limit \ No newline at end of file diff --git a/webapi/src/main/twirl/queries/sparql/v2/getGraphDataStandard.scala.txt b/webapi/src/main/twirl/queries/sparql/v2/getGraphDataStandard.scala.txt new file mode 100644 index 0000000000..ab91316c32 --- /dev/null +++ b/webapi/src/main/twirl/queries/sparql/v2/getGraphDataStandard.scala.txt @@ -0,0 +1,120 @@ +@* + * Copyright © 2015-2018 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._ +@import org.knora.webapi.util.SmartIri + +@** + * Gets the outbound or inbound links from/to a resource, using standard SPARQL, without inference. This query is used + * recursively to get a graph of resources reachable from a given resource. + * + * This template is used only by getGraphData.scala.txt. + * + * @param triplestore the name of the triplestore being used. + * @param startNodeIri the IRI of the resource to use as the starting point of the query. + * @param startNodeOnly if true, returns information only about the start node. + * @param maybeExcludeLinkProperty if provided, a link property that should be excluded from the results. + * @param outbound true to get outbound links, false to get inbound links. + * @param limit the maximum number of edges to return. + *@ +@(triplestore: String, + startNodeIri: IRI, + startNodeOnly: Boolean, + maybeExcludeLinkProperty: Option[SmartIri], + outbound: Boolean, + limit: Int) + +PREFIX rdf: +PREFIX rdfs: +PREFIX knora-base: + +SELECT ?node ?nodeClass ?nodeLabel ?nodeCreator ?nodeProject ?nodePermissions + ?linkValue ?linkProp ?linkValueCreator ?linkValuePermissions +WHERE { + @if(startNodeOnly) { + BIND(IRI("@startNodeIri") AS ?node) . + + ?node a ?nodeClass ; + rdfs:label ?nodeLabel ; + knora-base:attachedToUser ?nodeCreator ; + knora-base:attachedToProject ?nodeProject ; + knora-base:isDeleted false ; + knora-base:hasPermissions ?nodePermissions . + } else { + BIND(IRI("@startNodeIri") AS ?startNode) . + + ?linkProp rdfs:subPropertyOf* knora-base:hasLinkTo . + + @if(outbound) { + ?startNode ?linkProp ?node . + + @maybeExcludeLinkProperty match { + case Some(excludeLinkProperty) => { + + FILTER NOT EXISTS { + ?excludedProp rdfs:subPropertyOf* <@excludeLinkProperty> . + ?startNode ?excludedProp ?node . + } + + } + + case None => {} + } + + ?node knora-base:isDeleted false . + + ?linkValue a knora-base:LinkValue ; + rdf:subject ?startNode ; + rdf:predicate ?linkProp ; + rdf:object ?node . + } else { + ?node ?linkProp ?startNode . + + @maybeExcludeLinkProperty match { + case Some(excludeLinkProperty) => { + + FILTER NOT EXISTS { + ?excludedProp rdfs:subPropertyOf* <@excludeLinkProperty> . + ?node ?excludedProp ?startNode . + } + + } + + case None => {} + } + + ?node knora-base:isDeleted false . + + ?linkValue a knora-base:LinkValue ; + rdf:subject ?node ; + rdf:predicate ?linkProp ; + rdf:object ?startNode . + } + + ?node a ?nodeClass ; + rdfs:label ?nodeLabel ; + knora-base:attachedToUser ?nodeCreator ; + knora-base:attachedToProject ?nodeProject ; + knora-base:hasPermissions ?nodePermissions . + + ?linkValue knora-base:attachedToUser ?linkValueCreator ; + knora-base:hasPermissions ?linkValuePermissions . + } +} +LIMIT @limit \ No newline at end of file diff --git a/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBoth.jsonld b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBoth.jsonld new file mode 100644 index 0000000000..05907f675f --- /dev/null +++ b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBoth.jsonld @@ -0,0 +1,129 @@ +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + "@type" : "anything:Thing", + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + }, + "rdfs:label" : "Sierra" + }, { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow" + }, + "rdfs:label" : "excluded Bravo" + }, { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + "@type" : "anything:Thing", + "rdfs:label" : "Victor" + }, { + "@id" : "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + }, + "rdfs:label" : "Papa" + }, { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + }, + "rdfs:label" : "Uniform" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + }, { + "@id" : "http://rdfh.ch/0001/start" + } ], + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + }, + "rdfs:label" : "Whiskey" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw" + }, + "rdfs:label" : "Tango" + }, { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow", + "@type" : "anything:Thing", + "rdfs:label" : "November" + }, { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/cL5AwEioRLOm6Vrqwl1RmQ" + }, + "rdfs:label" : "excluded Alpha" + }, { + "@id" : "http://rdfh.ch/0001/cL5AwEioRLOm6Vrqwl1RmQ", + "@type" : "anything:Thing", + "rdfs:label" : "Oscar" + }, { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Quebec" + }, { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + }, + "rdfs:label" : "X-ray" + }, { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw", + "@type" : "anything:Thing", + "rdfs:label" : "excluded Charlie" + }, { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ", + "@type" : "anything:Thing", + "rdfs:label" : "Golf" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + } ], + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA" + }, + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} \ No newline at end of file diff --git a/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithDepth.jsonld b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithDepth.jsonld new file mode 100644 index 0000000000..4f0e2f174a --- /dev/null +++ b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithDepth.jsonld @@ -0,0 +1,99 @@ +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + "@type" : "anything:Thing", + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + }, + "rdfs:label" : "Sierra" + }, { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow" + }, + "rdfs:label" : "excluded Bravo" + }, { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + "@type" : "anything:Thing", + "rdfs:label" : "Victor" + }, { + "@id" : "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + }, + "rdfs:label" : "Papa" + }, { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw", + "@type" : "anything:Thing", + "rdfs:label" : "Uniform" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw" + }, + "rdfs:label" : "Tango" + }, { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow", + "@type" : "anything:Thing", + "rdfs:label" : "November" + }, { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw", + "@type" : "anything:Thing", + "rdfs:label" : "excluded Alpha" + }, { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Quebec" + }, { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw", + "@type" : "anything:Thing", + "rdfs:label" : "excluded Charlie" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + } ], + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA" + }, + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} \ No newline at end of file diff --git a/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithExcludedProp.jsonld b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithExcludedProp.jsonld new file mode 100644 index 0000000000..cf7f8ae4eb --- /dev/null +++ b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithExcludedProp.jsonld @@ -0,0 +1,84 @@ +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + "@type" : "anything:Thing", + "rdfs:label" : "Sierra" + }, { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + "@type" : "anything:Thing", + "rdfs:label" : "Victor" + }, { + "@id" : "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + }, + "rdfs:label" : "Papa" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + }, { + "@id" : "http://rdfh.ch/0001/start" + } ], + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + }, + "rdfs:label" : "Whiskey" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + }, + "rdfs:label" : "Tango" + }, { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Quebec" + }, { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + }, + "rdfs:label" : "X-ray" + }, { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ", + "@type" : "anything:Thing", + "rdfs:label" : "Golf" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + } ], + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} \ No newline at end of file diff --git a/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphInbound.jsonld b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphInbound.jsonld new file mode 100644 index 0000000000..ceea1a3ed2 --- /dev/null +++ b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphInbound.jsonld @@ -0,0 +1,59 @@ +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + }, + "rdfs:label" : "Papa" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + }, + "rdfs:label" : "Whiskey" + }, { + "@id" : "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/start" + }, + "rdfs:label" : "Quebec" + }, { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + }, + "rdfs:label" : "X-ray" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + }, + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} \ No newline at end of file diff --git a/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphOutbound.jsonld b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphOutbound.jsonld new file mode 100644 index 0000000000..9a7fc3ed7b --- /dev/null +++ b/webapi/src/test/resources/test-data/resourcesR2RV2/ThingGraphOutbound.jsonld @@ -0,0 +1,112 @@ +{ + "@graph" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + "@type" : "anything:Thing", + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + }, + "rdfs:label" : "Sierra" + }, { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow" + }, + "rdfs:label" : "excluded Bravo" + }, { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + "@type" : "anything:Thing", + "rdfs:label" : "Victor" + }, { + "@id" : "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + }, + "rdfs:label" : "Uniform" + }, { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + }, { + "@id" : "http://rdfh.ch/0001/start" + } ], + "rdfs:label" : "Foxtrot" + }, { + "@id" : "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + }, + "rdfs:label" : "Whiskey" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw" + }, + "rdfs:label" : "Tango" + }, { + "@id" : "http://rdfh.ch/0001/YgL4JhwfSQq-1davjrp8Ow", + "@type" : "anything:Thing", + "rdfs:label" : "November" + }, { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/cL5AwEioRLOm6Vrqwl1RmQ" + }, + "rdfs:label" : "excluded Alpha" + }, { + "@id" : "http://rdfh.ch/0001/cL5AwEioRLOm6Vrqwl1RmQ", + "@type" : "anything:Thing", + "rdfs:label" : "Oscar" + }, { + "@id" : "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + "@type" : "anything:Thing", + "rdfs:label" : "X-ray" + }, { + "@id" : "http://rdfh.ch/0001/nVh9YJ1ZStSmCHb9hIdwWw", + "@type" : "anything:Thing", + "rdfs:label" : "excluded Charlie" + }, { + "@id" : "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ", + "@type" : "anything:Thing", + "rdfs:label" : "Golf" + }, { + "@id" : "http://rdfh.ch/0001/start", + "@type" : "anything:Thing", + "anything:hasOtherThing" : [ { + "@id" : "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + }, { + "@id" : "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + } ], + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/9eSuQ_J7T0aOqoImTkwPxA" + }, + "rdfs:label" : "Romeo" + }, { + "@id" : "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + "@type" : "anything:Thing", + "anything:hasOtherThing" : { + "@id" : "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + }, + "anything:isPartOfOtherThing" : { + "@id" : "http://rdfh.ch/0001/ZYNUiHtsTROWblui9xHEuw" + }, + "rdfs:label" : "Echo" + } ], + "@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#" + } +} \ No newline at end of file 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 0c65a3c176..1622649fe5 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 @@ -26,12 +26,13 @@ import akka.actor.ActorSystem import akka.http.scaladsl.model.headers.{Accept, BasicHttpCredentials} import akka.http.scaladsl.model.{HttpEntity, HttpResponse, MediaRange, StatusCodes} import akka.http.scaladsl.testkit.RouteTestTimeout +import com.typesafe.config.{Config, ConfigFactory} import org.knora.webapi._ import org.knora.webapi.e2e.v2.ResponseCheckerR2RV2.compareJSONLDForResourcesResponse import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject import org.knora.webapi.routing.RouteUtilV2 import org.knora.webapi.util.IriConversions._ -import org.knora.webapi.util.jsonld.{JsonLDConstants, JsonLDDocument} +import org.knora.webapi.util.jsonld.{JsonLDConstants, JsonLDDocument, JsonLDUtil} import org.knora.webapi.util.{FileUtil, StringFormatter} import scala.concurrent.ExecutionContextExecutor @@ -39,7 +40,7 @@ import scala.concurrent.ExecutionContextExecutor /** * Tests the API v2 resources route. */ -class ResourcesRouteV2E2ESpec extends E2ESpec { +class ResourcesRouteV2E2ESpec extends E2ESpec(ResourcesRouteV2E2ESpec.config) { private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance implicit def default(implicit system: ActorSystem): RouteTestTimeout = RouteTestTimeout(settings.defaultTimeout) @@ -233,6 +234,99 @@ class ResourcesRouteV2E2ESpec extends E2ESpec { compareJSONLDForResourcesResponse(expectedJSONLD = expectedAnswerJSONLD, receivedJSONLD = responseAsString) } + "return a graph of resources reachable via links from/to a given resource" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=both") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.OK, responseAsString) + val parsedReceivedJsonLD = JsonLDUtil.parseJsonLD(responseAsString) + + val expectedAnswerJSONLD = readOrWriteTextFile(responseAsString, new File("src/test/resources/test-data/resourcesR2RV2/ThingGraphBoth.jsonld"), writeTestDataFiles) + val parsedExpectedJsonLD = JsonLDUtil.parseJsonLD(expectedAnswerJSONLD) + + assert(parsedReceivedJsonLD == parsedExpectedJsonLD) + } + + "return a graph of resources reachable via links from a given resource" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=outbound") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.OK, responseAsString) + val parsedReceivedJsonLD = JsonLDUtil.parseJsonLD(responseAsString) + + val expectedAnswerJSONLD = readOrWriteTextFile(responseAsString, new File("src/test/resources/test-data/resourcesR2RV2/ThingGraphOutbound.jsonld"), writeTestDataFiles) + val parsedExpectedJsonLD = JsonLDUtil.parseJsonLD(expectedAnswerJSONLD) + + assert(parsedReceivedJsonLD == parsedExpectedJsonLD) + } + + "return a graph of resources reachable via links to a given resource" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=inbound") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.OK, responseAsString) + val parsedReceivedJsonLD = JsonLDUtil.parseJsonLD(responseAsString) + + val expectedAnswerJSONLD = readOrWriteTextFile(responseAsString, new File("src/test/resources/test-data/resourcesR2RV2/ThingGraphInbound.jsonld"), writeTestDataFiles) + val parsedExpectedJsonLD = JsonLDUtil.parseJsonLD(expectedAnswerJSONLD) + + assert(parsedReceivedJsonLD == parsedExpectedJsonLD) + } + + "return a graph of resources reachable via links to/from a given resource, excluding a specified property" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=both&excludeProperty=${URLEncoder.encode("http://0.0.0.0:3333/ontology/0001/anything/v2#isPartOfOtherThing", "UTF-8")}") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.OK, responseAsString) + val parsedReceivedJsonLD = JsonLDUtil.parseJsonLD(responseAsString) + + val expectedAnswerJSONLD = readOrWriteTextFile(responseAsString, new File("src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithExcludedProp.jsonld"), writeTestDataFiles) + val parsedExpectedJsonLD = JsonLDUtil.parseJsonLD(expectedAnswerJSONLD) + + assert(parsedReceivedJsonLD == parsedExpectedJsonLD) + } + + "return a graph of resources reachable via links from a given resource, specifying search depth" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=both&depth=2") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.OK, responseAsString) + val parsedReceivedJsonLD = JsonLDUtil.parseJsonLD(responseAsString) + + val expectedAnswerJSONLD = readOrWriteTextFile(responseAsString, new File("src/test/resources/test-data/resourcesR2RV2/ThingGraphBothWithDepth.jsonld"), writeTestDataFiles) + val parsedExpectedJsonLD = JsonLDUtil.parseJsonLD(expectedAnswerJSONLD) + + assert(parsedReceivedJsonLD == parsedExpectedJsonLD) + } + + "not accept a graph request with an invalid direction" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?direction=foo") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.BadRequest, responseAsString) + } + + "not accept a graph request with an invalid depth (< 1)" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?depth=0") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.BadRequest, responseAsString) + } + + "not accept a graph request with an invalid depth (> max)" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?depth=${settings.maxGraphBreadth + 1}") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.BadRequest, responseAsString) + } + + "not accept a graph request with an invalid property to exclude" in { + val request = Get(s"$baseApiUrl/v2/graph/${URLEncoder.encode("http://rdfh.ch/0001/start", "UTF-8")}?excludeProperty=foo") + val response: HttpResponse = singleAwaitingRequest(request) + val responseAsString = responseToString(response) + assert(response.status == StatusCodes.BadRequest, responseAsString) + } + "create a resource with values" in { val jsonLdEntity = """{ @@ -339,4 +433,12 @@ class ResourcesRouteV2E2ESpec extends E2ESpec { assert(resourceIri.toSmartIri.isKnoraDataIri) } } -} \ No newline at end of file +} + +object ResourcesRouteV2E2ESpec { + val config: Config = ConfigFactory.parseString( + """akka.loglevel = "DEBUG" + |akka.stdout-loglevel = "DEBUG" + |app.triplestore.profile-queries = false + """.stripMargin) +} 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 c66105253f..afe2921725 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 @@ -25,7 +25,6 @@ import akka.testkit.{ImplicitSender, TestActorRef} import org.knora.webapi._ import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.store.triplestoremessages.RdfDataObject -import org.knora.webapi.messages.v1.responder.valuemessages.{KnoraCalendarV1, KnoraPrecisionV1} import org.knora.webapi.messages.v2.responder.resourcemessages._ import org.knora.webapi.messages.v2.responder.standoffmessages.{GetMappingRequestV2, GetMappingResponseV2, MappingXMLtoStandoff, StandoffDataTypeClasses} import org.knora.webapi.messages.v2.responder.valuemessages._ @@ -79,6 +78,327 @@ object ResourcesResponderV2Spec { ) } +class GraphTestData { + private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + val graphForAnythingUser1 = GraphDataGetResponseV2( + edges = Vector( + GraphEdgeV2( + target = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/cmfk1DMHRBiR4-_6HXpEFA", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/5IEswyQFQp2bxXDrOyEfEA" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/7uuGcnFcQJq08dMOralyCQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/start", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/start", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/cmfk1DMHRBiR4-_6HXpEFA" + ) + ), + nodes = Vector( + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Tango", + resourceIri = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Foxtrot", + resourceIri = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Echo", + resourceIri = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Golf", + resourceIri = "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Whiskey", + resourceIri = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Mike", + resourceIri = "http://rdfh.ch/0001/cmfk1DMHRBiR4-_6HXpEFA" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "X-ray", + resourceIri = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Uniform", + resourceIri = "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Sierra", + resourceIri = "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Romeo", + resourceIri = "http://rdfh.ch/0001/start" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Quebec", + resourceIri = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Hotel", + resourceIri = "http://rdfh.ch/0001/7uuGcnFcQJq08dMOralyCQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Papa", + resourceIri = "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Victor", + resourceIri = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Delta", + resourceIri = "http://rdfh.ch/0001/5IEswyQFQp2bxXDrOyEfEA" + ) + ), + ontologySchema = InternalSchema + ) + + val graphForIncunabulaUser = GraphDataGetResponseV2( + edges = Vector( + GraphEdgeV2( + target = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/start", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/start", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + ), + GraphEdgeV2( + target = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A", + propertyIri = "http://www.knora.org/ontology/0001/anything#hasOtherThing".toSmartIri, + source = "http://rdfh.ch/0001/start" + ) + ), + nodes = Vector( + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Tango", + resourceIri = "http://rdfh.ch/0001/WLSHxQUgTOmG1T0lBU2r5w" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Foxtrot", + resourceIri = "http://rdfh.ch/0001/Lz7WEqJETJqqsUZQYexBQg" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Echo", + resourceIri = "http://rdfh.ch/0001/tPfZeNMvRVujCQqbIbvO0A" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Golf", + resourceIri = "http://rdfh.ch/0001/sHCLAGg-R5qJ6oPZPV-zOQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Whiskey", + resourceIri = "http://rdfh.ch/0001/MiBwAFcxQZGHNL-WfgFAPQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "X-ray", + resourceIri = "http://rdfh.ch/0001/nResNuvARcWYUdWyo0GWGw" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Uniform", + resourceIri = "http://rdfh.ch/0001/LOV-6aLYQFW15jwdyS51Yw" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Sierra", + resourceIri = "http://rdfh.ch/0001/0C-0L1kORryKzJAJxxRyRQ" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Romeo", + resourceIri = "http://rdfh.ch/0001/start" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Quebec", + resourceIri = "http://rdfh.ch/0001/iqW_PBiHRdyTFzik8tuSog" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Papa", + resourceIri = "http://rdfh.ch/0001/L5xU7Qe5QUu6Wz3cDaCxbA" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Victor", + resourceIri = "http://rdfh.ch/0001/A67ka6UQRHWf313tbhQBjw" + ) + ), + ontologySchema = InternalSchema + ) + + val graphWithStandoffLink = GraphDataGetResponseV2( + edges = Vector(GraphEdgeV2( + target = "http://rdfh.ch/0001/a-thing", + propertyIri = "http://www.knora.org/ontology/knora-base#hasStandoffLinkTo".toSmartIri, + source = "http://rdfh.ch/0001/a-thing-with-text-values" + )), + nodes = Vector( + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Ein Ding f\u00FCr jemanden, dem die Dinge gefallen", + resourceIri = "http://rdfh.ch/0001/a-thing-with-text-values" + ), + GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "A thing", + resourceIri = "http://rdfh.ch/0001/a-thing" + ) + ), + ontologySchema = InternalSchema + ) + + val graphWithOneNode = GraphDataGetResponseV2( + edges = Nil, + nodes = Vector(GraphNodeV2( + resourceClassIri = "http://www.knora.org/ontology/0001/anything#Thing".toSmartIri, + resourceLabel = "Another thing", + resourceIri = "http://rdfh.ch/0001/another-thing" + )), + ontologySchema = InternalSchema + ) +} + /** * Tests [[ResourcesResponderV2]]. */ @@ -94,6 +414,8 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { private var standardMapping: Option[MappingXMLtoStandoff] = None + private val graphTestData = new GraphTestData + override lazy val rdfDataObjects = List( RdfDataObject(path = "_test_data/all_data/incunabula-data.ttl", name = "http://www.knora.org/data/0803/incunabula"), RdfDataObject(path = "_test_data/demo_data/images-demo-data.ttl", name = "http://www.knora.org/data/00FF/images"), @@ -289,6 +611,72 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { } + "return a graph of resources reachable via links from/to a given resource" in { + actorUnderTest ! GraphDataGetRequestV2( + resourceIri = "http://rdfh.ch/0001/start", + depth = 6, + inbound = true, + outbound = true, + excludeProperty = Some(OntologyConstants.KnoraApiV2WithValueObjects.IsPartOf.toSmartIri), + requestingUser = SharedTestDataADM.anythingUser1 + ) + + val response = expectMsgType[GraphDataGetResponseV2](timeout) + val edges = response.edges + val nodes = response.nodes + + edges should contain theSameElementsAs graphTestData.graphForAnythingUser1.edges + nodes should contain theSameElementsAs graphTestData.graphForAnythingUser1.nodes + } + + "return a graph of resources reachable via links from/to a given resource, filtering the results according to the user's permissions" in { + actorUnderTest ! GraphDataGetRequestV2( + resourceIri = "http://rdfh.ch/0001/start", + depth = 6, + inbound = true, + outbound = true, + excludeProperty = Some(OntologyConstants.KnoraApiV2WithValueObjects.IsPartOf.toSmartIri), + requestingUser = SharedTestDataADM.incunabulaProjectAdminUser + ) + + val response = expectMsgType[GraphDataGetResponseV2](timeout) + val edges = response.edges + val nodes = response.nodes + + edges should contain theSameElementsAs graphTestData.graphForIncunabulaUser.edges + nodes should contain theSameElementsAs graphTestData.graphForIncunabulaUser.nodes + } + + "return a graph containing a standoff link" in { + actorUnderTest ! GraphDataGetRequestV2( + resourceIri = "http://rdfh.ch/0001/a-thing", + depth = 4, + inbound = true, + outbound = true, + excludeProperty = Some(OntologyConstants.KnoraApiV2WithValueObjects.IsPartOf.toSmartIri), + requestingUser = SharedTestDataADM.anythingUser1 + ) + + expectMsgPF(timeout) { + case response: GraphDataGetResponseV2 => response should ===(graphTestData.graphWithStandoffLink) + } + } + + "return a graph containing just one node" in { + actorUnderTest ! GraphDataGetRequestV2( + resourceIri = "http://rdfh.ch/0001/another-thing", + depth = 4, + inbound = true, + outbound = true, + excludeProperty = Some(OntologyConstants.KnoraApiV2WithValueObjects.IsPartOf.toSmartIri), + requestingUser = SharedTestDataADM.anythingUser1 + ) + + expectMsgPF(timeout) { + case response: GraphDataGetResponseV2 => response should ===(graphTestData.graphWithOneNode) + } + } + "create a resource with no values" in { // Create the resource. @@ -1003,5 +1391,6 @@ class ResourcesResponderV2Spec extends CoreSpec() with ImplicitSender { } } + } }