diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md
index 82d2b0c357..975d6fda2f 100644
--- a/docs/source/endpoints/index.md
+++ b/docs/source/endpoints/index.md
@@ -41,6 +41,7 @@ principals
querystring
querystringsearch
registry
+relations
roles
searching
system
diff --git a/docs/source/endpoints/relations.md b/docs/source/endpoints/relations.md
new file mode 100644
index 0000000000..2e60bf84ee
--- /dev/null
+++ b/docs/source/endpoints/relations.md
@@ -0,0 +1,314 @@
+---
+myst:
+ html_meta:
+ "description": "Create, query, and delete relations between content items with the relations endpoint."
+ "property=og:description": "Create, query, and delete relations between content items with the relations endpoint."
+ "property=og:title": "Relations"
+ "keywords": "Plone, plone.restapi, REST, API, relations, service, endpoint"
+---
+
+(restapi-relations-label)=
+
+# Relations
+
+Plone's relations represent binary relationships between content objects.
+
+A single relation is defined by source, target, and relation name.
+
+You can define relations either with content type schema fields `RelationChoice` or `RelationList`, or with types `isReferencing` or `iterate-working-copy`.
+
+- Relations based on fields of a content type schema are editable by users.
+- The relations `isReferencing` (block text links to a Plone content object) and `iterate-working-copy` (working copy is enabled and the content object is a working copy) are not editable.
+ They are created and deleted with links in text, respectively creating and deleting working copies.
+
+You can create, query, and delete relations by interacting through the `@relations` endpoint on the site root.
+Querying relations with the `@relations` endpoint requires the `zope2.View` permission on both the source and target objects.
+Therefore results include relations if and only if both the source and target are accessible by the querying user.
+Creating and deleting relations requires `zope2.View` permission on the target object and `cmf.ModifyPortalContent` permission on the source object.
+
+(restapi-relations-getting-statistics-for-all-relations-label)=
+
+## Getting statistics for all relations
+
+The call without any parameters returns statistics on all existing relations to which the user has access.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp
+:language: http
+```
+
+(restapi-relations-querying-relations-label)=
+
+## Querying relations
+
+You can query relations by a single source, target, or relation type.
+Combinations are allowed.
+The source and target must be either a UID or path.
+
+Queried relations require the `View` permission on the source and target.
+If the user lacks permission to access these relations, then they are omitted from the query results.
+
+The relations are grouped by relation name, source, and target, and are provided in a summarized format.
+
+
+Query relations of a **relation type**:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_get_relationname.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_relationname.resp
+:language: http
+```
+
+Query relations of a **source** object by path:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_path.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp
+:language: http
+```
+
+Query relations of a **source** object by UID:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp
+:language: http
+```
+
+Query relations by **relation name and source**:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp
+:language: http
+```
+
+Query relations to a **target**:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_get_target.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_target.resp
+:language: http
+```
+
+### Refining
+
+Querying can be further refined by applying the `query_target` search parameter to restrict the source or target to either contain a search string or be located under a path.
+
+Search string example:
+
+```
+/@relations?relation=comprisesComponentPart&query_target=wheel
+```
+
+Path example:
+
+```
+/@relations?relation=comprisesComponentPart&query_target=/inside/garden
+```
+
+### Limit the results
+
+Limit the number of results by `max` to, for example, at most 100 results:
+
+```
+/@relations?relation=comprisesComponentPart&source=/documents/doc-1&max=100
+```
+
+### Only broken relations
+
+Retrieve items with broken relations by querying with `onlyBroken`:
+
+```
+/@relations?onlyBroken=true
+```
+
+This returns a JSON object, for example:
+
+```json
+{
+ "@id": "http://localhost:55001/plone/@relations?onlyBroken=true",
+ "relations": {
+ "relatedItems": {
+ "items": [
+ "http://localhost:55001/plone/document-2",
+ ],
+ "items_total": 1
+ }
+ }
+}
+```
+
+
+(restapi-relations-creating-relations-label)=
+
+## Creating relations
+
+You can create relations by providing a list of the source, target, and name of the relation.
+The source and target must be either a UID or path.
+
+If the relation is based on a `RelationChoice` or `RelationList` field of the source object, the value of the field is updated accordingly.
+
+Create a relation by **path**:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_post.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post.resp
+:language: http
+```
+
+Create a relation by **UID**:
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_post_with_uid.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post_with_uid.resp
+:language: http
+```
+
+If either the source or target do not exist, then an attempt to create a relation will fail, and will return a `422 Unprocessable Entity` response.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_post_failure.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post_failure.resp
+:language: http
+```
+
+(restapi-relations-deleting-relations-label)=
+
+## Deleting relations
+
+You can delete relations by relation name, source object, target object, or a combination of these.
+You can also delete relations by providing a list of relations.
+
+If a deleted relation is based on a `RelationChoice` or `RelationList` field, the value of the field is removed or updated accordingly on the source object.
+
+### Delete a list of relations
+
+You can delete relations by either UID or path.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_path_uid.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_path_uid.resp
+:language: http
+```
+
+If either the source or target do not exist, then an attempt to delete a relation will fail, and will return a `422 Unprocessable Entity` response.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_failure.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_failure.resp
+:language: http
+```
+
+### Delete relations by relation name
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_relationname.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_relationname.resp
+:language: http
+```
+
+### Delete relations by source
+
+You can delete relations by either source UID or path.
+
+The following example shows how to delete a relation by source path.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_source.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_source.resp
+:language: http
+```
+
+### Delete relations by target
+
+You can delete relations by either target UID or path.
+
+The following example shows how to delete a relation by target path.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_target.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_target.resp
+:language: http
+```
+
+### Delete relations by a combination of source, target, and relation name
+
+You can delete relations by a combination of either any two of their relation name, source, and target, or a combination of all three.
+In the following example, you would delete a relation by its relation name and target.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_del_combi.req
+```
+
+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_combi.resp
+:language: http
+```
+
+
+## Fix relations
+
+Broken relations can be fixed by releasing and re-indexing them.
+A successfully fixed relation will return a `204 No Content` response.
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_rebuild.req
+```
+
+In rare cases, you may need to flush the `intIds`.
+You can rebuild relations by flushing the `intIds` with the following HTTP POST request.
+
+```{warning}
+If your code relies on `intIds`, you should take caution and think carefully before you flush them.
+```
+
+
+```{eval-rst}
+.. http:example:: curl httpie python-requests
+ :request: ../../../src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req
+```
diff --git a/news/1432.feature b/news/1432.feature
new file mode 100644
index 0000000000..abcc204934
--- /dev/null
+++ b/news/1432.feature
@@ -0,0 +1 @@
+Create relations service. Query, add, delete. @ksuess
\ No newline at end of file
diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml
index 0af1402ec3..1fd3c0568f 100644
--- a/src/plone/restapi/services/configure.zcml
+++ b/src/plone/restapi/services/configure.zcml
@@ -30,6 +30,7 @@
+
diff --git a/src/plone/restapi/services/relations/__init__.py b/src/plone/restapi/services/relations/__init__.py
new file mode 100644
index 0000000000..e5c9bfc3f0
--- /dev/null
+++ b/src/plone/restapi/services/relations/__init__.py
@@ -0,0 +1,36 @@
+try:
+ from plone.api.relation import create as api_relation_create
+ from plone.api.relation import delete as api_relation_delete
+except ImportError:
+ api_relation_create = None
+ api_relation_delete = None
+
+from plone.app.uuid.utils import uuidToObject
+from Products.CMFCore.DynamicType import DynamicType
+from zope.component.hooks import getSite
+
+
+def plone_api_content_get(path=None, UID=None):
+ """Get an object.
+
+ copy pasted from plone.api
+ """
+ if path:
+ site = getSite()
+ site_absolute_path = "/".join(site.getPhysicalPath())
+ if not path.startswith("{path}".format(path=site_absolute_path)):
+ path = "{site_path}{relative_path}".format(
+ site_path=site_absolute_path,
+ relative_path=path,
+ )
+ try:
+ content = site.restrictedTraverse(path)
+ except (KeyError, AttributeError):
+ return None # When no object is found don't raise an error
+ else:
+ # Only return a content if it implements DynamicType,
+ # which is true for Dexterity content and Comment (plone.app.discussion)
+ return content if isinstance(content, DynamicType) else None
+
+ elif UID:
+ return uuidToObject(UID)
diff --git a/src/plone/restapi/services/relations/add.py b/src/plone/restapi/services/relations/add.py
new file mode 100644
index 0000000000..a86eac0732
--- /dev/null
+++ b/src/plone/restapi/services/relations/add.py
@@ -0,0 +1,129 @@
+from AccessControl.SecurityManagement import getSecurityManager
+from plone import api
+from plone.restapi.deserializer import json_body
+from plone.restapi.services import Service
+from plone.restapi.services.relations import plone_api_content_get
+from plone.restapi.services.relations import api_relation_create
+from Products.CMFCore.permissions import ManagePortal
+from zope.interface import alsoProvides
+from zope.interface import implementer
+from zope.publisher.interfaces import IPublishTraverse
+import plone.protect.interfaces
+import logging
+
+
+log = logging.getLogger(__name__)
+
+try:
+ from Products.CMFPlone.relationhelper import rebuild_relations
+except ImportError:
+ try:
+ from collective.relationhelpers.api import rebuild_relations
+ except ImportError:
+ rebuild_relations = None
+
+
+@implementer(IPublishTraverse)
+class PostRelations(Service):
+ """Create new relations."""
+
+ def __init__(self, context, request):
+ super().__init__(context, request)
+ self.params = []
+ self.sm = getSecurityManager()
+
+ def publishTraverse(self, request, name):
+ # Treat any path segments after /@relations as parameters
+ self.params.append(name)
+ return self
+
+ def reply(self):
+ # Disable CSRF protection
+ if "IDisableCSRFProtection" in dir(plone.protect.interfaces):
+ alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection)
+
+ if not api_relation_create:
+ raise NotImplementedError()
+
+ data = json_body(self.request)
+
+ # Rebuild relations with or without regenerating intIds
+ if "rebuild" in self.params:
+ if api.user.has_permission(ManagePortal):
+ if rebuild_relations:
+ flush = True if data.get("flush", False) else False
+ try:
+ rebuild_relations(flush_and_rebuild_intids=flush)
+ print("*** Relations rebuild. flush:", flush)
+ return self.reply_no_content()
+ except Exception as e:
+ self.request.response.setStatus(500)
+ return dict(
+ error=dict(
+ # type="ImportError",
+ message=str(e),
+ )
+ )
+ else:
+ self.request.response.setStatus(501)
+ return dict(
+ error=dict(
+ type="ImportError",
+ message="Relationhelpers not available. Install collective.relationhelpers or upgrade to Plone 6!",
+ )
+ )
+ else:
+ self.request.response.setStatus(403)
+ return dict(
+ error=dict(
+ type="Forbidden",
+ )
+ )
+
+ failed_relations = []
+ for relationdata in data["items"]:
+ source_obj = plone_api_content_get(UID=relationdata["source"])
+ if not source_obj:
+ source_obj = plone_api_content_get(path=relationdata["source"])
+ target_obj = plone_api_content_get(UID=relationdata["target"])
+ if not target_obj:
+ target_obj = plone_api_content_get(path=relationdata["target"])
+
+ if not source_obj or not target_obj:
+ msg = (
+ "Source and target not found."
+ if not source_obj and not target_obj
+ else "Source not found."
+ if not source_obj
+ else "Target not found."
+ )
+ msg = f"Failed on creating a relation. {msg}"
+ log.error(f"{msg} {relationdata}")
+ failed_relations.append((relationdata, msg))
+ continue
+
+ try:
+ api_relation_create(
+ source=source_obj,
+ target=target_obj,
+ relationship=relationdata["relation"],
+ )
+ except Exception as e:
+ msg = f"{type(e).__name__}: {str(e)}. Failed on creating a relation. source:{source_obj}, target: {target_obj}"
+ log.error(f"{msg} {relationdata}")
+ failed_relations.append((relationdata, msg))
+ continue
+
+ if len(failed_relations) > 0:
+ return self._error(
+ 422,
+ "Unprocessable Content",
+ "Failed on creating relations",
+ failed_relations,
+ )
+
+ return self.reply_no_content()
+
+ def _error(self, status, type, message, failed=[]):
+ self.request.response.setStatus(status)
+ return {"error": {"type": type, "message": message, "failed": failed}}
diff --git a/src/plone/restapi/services/relations/configure.zcml b/src/plone/restapi/services/relations/configure.zcml
new file mode 100644
index 0000000000..f21cd0ac15
--- /dev/null
+++ b/src/plone/restapi/services/relations/configure.zcml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/plone/restapi/services/relations/delete.py b/src/plone/restapi/services/relations/delete.py
new file mode 100644
index 0000000000..5d8f691191
--- /dev/null
+++ b/src/plone/restapi/services/relations/delete.py
@@ -0,0 +1,113 @@
+from plone.restapi.deserializer import json_body
+from plone.restapi.services import Service
+from plone.restapi.services.relations import plone_api_content_get
+from plone.restapi.services.relations import api_relation_delete
+from zope.interface import alsoProvides
+import plone.protect.interfaces
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+class DeleteRelations(Service):
+ """Delete relations."""
+
+ def reply(self):
+ # Disable CSRF protection
+ if "IDisableCSRFProtection" in dir(plone.protect.interfaces):
+ alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection)
+
+ if not api_relation_delete:
+ raise NotImplementedError()
+
+ data = json_body(self.request)
+
+ failed_relations = []
+ # List of single relations
+ if data.get("items", None):
+ for relationdata in data["items"]:
+ # UIDs provided?
+ source_obj = plone_api_content_get(UID=relationdata["source"])
+ target_obj = plone_api_content_get(UID=relationdata["target"])
+ # Or maybe path provided?
+ if not source_obj:
+ source_obj = plone_api_content_get(path=relationdata["source"])
+ if not target_obj:
+ target_obj = plone_api_content_get(path=relationdata["target"])
+ # Source or target not found by UID or path
+ if not source_obj or not target_obj:
+ msg = (
+ "Source and target not found."
+ if not source_obj and not target_obj
+ else "Source not found."
+ if not source_obj
+ else "Target not found."
+ )
+ msg = f"Failed on deleting a relation. {msg}"
+ log.error(f"{msg} {relationdata}")
+ failed_relations.append((relationdata, msg))
+ continue
+
+ try:
+ api_relation_delete(
+ source=source_obj,
+ target=target_obj,
+ relationship=relationdata["relation"],
+ )
+ except Exception as e:
+ msg = f"{type(e).__name__}: {str(e)}. Failed on deleting a relation. source:{source_obj}, target: {target_obj}"
+ log.error(f"{msg} {relationdata}")
+ failed_relations.append((relationdata, msg))
+ continue
+
+ if len(failed_relations) > 0:
+ return self._error(
+ 422,
+ "Unprocessable Content",
+ "Failed on deleting relations",
+ failed_relations,
+ )
+
+ # Bunch of relations defined by source, target, relation name, or a combination of them
+ else:
+ relation = data.get("relation", None)
+ source = data.get("source", None)
+ target = data.get("target", None)
+
+ source_obj = None
+ if source:
+ source_obj = plone_api_content_get(UID=source)
+ if not source_obj:
+ source_obj = plone_api_content_get(path=source)
+ if not source_obj:
+ msg = f"Failed on deleting relations. Source not found: {source}"
+ log.error(msg)
+ return self._error(422, "Unprocessable Content", msg)
+
+ target_obj = None
+ if target:
+ target_obj = plone_api_content_get(UID=target)
+ if not target_obj:
+ target_obj = plone_api_content_get(path=target)
+ if not target_obj:
+ msg = f"Failed on deleting relations. Target not found: {target}"
+ log.error(msg)
+ return self._error(422, "Unprocessable Content", msg)
+
+ try:
+ api_relation_delete(
+ source=source_obj,
+ target=target_obj,
+ relationship=relation,
+ )
+ except Exception as e:
+ msg = f"{type(e).__name__}: {str(e)}. Failed on deleting a relation. source:{source}, target: {target}, relation: {relation}"
+ log.error(f"{msg} {data}")
+ return self._error(422, type(e).__name__, msg)
+
+ return self.reply_no_content()
+
+ def _error(self, status, type, message, failed=[]):
+ self.request.response.setStatus(status)
+ return {"error": {"type": type, "message": message, "failed": failed}}
diff --git a/src/plone/restapi/services/relations/get.py b/src/plone/restapi/services/relations/get.py
new file mode 100644
index 0000000000..ea79c96b5d
--- /dev/null
+++ b/src/plone/restapi/services/relations/get.py
@@ -0,0 +1,305 @@
+from AccessControl.SecurityManagement import getSecurityManager
+from collections import defaultdict
+from plone.restapi.interfaces import ISerializeToJsonSummary
+from plone.restapi.serializer.converters import json_compatible
+from plone.restapi.services import Service
+from plone.restapi.services.relations import api_relation_create
+from plone.restapi.services.relations import plone_api_content_get
+from Products.CMFCore.utils import getToolByName
+from zc.relation import catalog as zcr_catalog
+from zc.relation.interfaces import ICatalog
+from zExceptions import Unauthorized
+from zope.component import getMultiAdapter
+from zope.component import getUtility
+from zope.component import queryUtility
+from zope.component.hooks import getSite
+from zope.globalrequest import getRequest
+from zope.intid.interfaces import IIntIds
+from zope.intid.interfaces import IntIdMissingError
+from zope.interface import alsoProvides
+from zope.schema.interfaces import IVocabularyFactory
+
+import plone.protect.interfaces
+
+MAX = 2500
+
+try:
+ from Products.CMFPlone.relationhelper import get_relations_stats
+except ImportError:
+ try:
+ from collective.relationhelpers.api import get_relations_stats
+ except ImportError:
+ get_relations_stats = None
+
+try:
+ from Products.CMFPlone.relationhelper import rebuild_relations
+except ImportError:
+ try:
+ from collective.relationhelpers.api import rebuild_relations
+ except ImportError:
+ rebuild_relations = None
+
+
+def make_summary(obj, request):
+ """Add UID to metadata_fields."""
+ metadata_fields = request.form.get("metadata_fields", []) or []
+ if not isinstance(metadata_fields, list):
+ metadata_fields = [metadata_fields]
+ metadata_fields.append("UID")
+ request.form["metadata_fields"] = list(set(metadata_fields))
+ summary = getMultiAdapter((obj, request), ISerializeToJsonSummary)()
+ summary = json_compatible(summary)
+ return summary
+
+
+def get_relations(
+ sources=None,
+ targets=None,
+ relationship=None,
+ request=None,
+ unrestricted=False,
+ onlyBroken=False,
+ max=None,
+):
+ """Get valid relations."""
+ results = defaultdict(list)
+ if request is None:
+ request = getRequest()
+ intids = getUtility(IIntIds)
+ relation_catalog = queryUtility(ICatalog)
+ if relation_catalog is None:
+ return results
+
+ query = {}
+ if sources is not None:
+ iids = []
+ for el in sources:
+ try:
+ iids.append(intids.getId(el))
+ except IntIdMissingError:
+ continue
+ query["from_id"] = zcr_catalog.any(*iids)
+
+ if targets is not None:
+ iids = []
+ for el in targets:
+ try:
+ iids.append(intids.getId(el))
+ except IntIdMissingError:
+ continue
+ query["to_id"] = zcr_catalog.any(*iids)
+ if relationship is not None:
+ query["from_attribute"] = relationship
+
+ if not unrestricted:
+ checkPermission = getSecurityManager().checkPermission
+
+ if max:
+ try:
+ max = int(max)
+ except TypeError as e:
+ raise ValueError(str(e))
+ count = 0
+ relations = relation_catalog.findRelations(query)
+ for relation in relations:
+ if relation.isBroken():
+ if not onlyBroken:
+ continue
+ else:
+ if onlyBroken:
+ continue
+ count += 1
+ if max and count > max:
+ break
+
+ source_obj = relation.from_object
+ target_obj = relation.to_object
+
+ if not unrestricted:
+ can_view = (not source_obj or checkPermission("View", source_obj)) and (
+ not target_obj or checkPermission("View", target_obj)
+ )
+ if not can_view:
+ continue
+
+ if onlyBroken:
+ results[relation.from_attribute].append(
+ [
+ source_obj and source_obj.absolute_url() or "",
+ target_obj and target_obj.absolute_url() or "",
+ ]
+ )
+ else:
+ results[relation.from_attribute].append(
+ {
+ "source": make_summary(source_obj, request),
+ "target": make_summary(target_obj, request),
+ }
+ )
+ return results
+
+
+def relation_stats():
+ if get_relations_stats is not None:
+ rels, broken = get_relations_stats()
+ results = {"stats": rels, "broken": broken}
+ return json_compatible(results)
+ else:
+ raise NotImplementedError("Not implemented in this version of Plone")
+
+
+def getBrokenRelationNames():
+ relation_catalog = queryUtility(ICatalog)
+ if relation_catalog is None:
+ return []
+
+ relations = relation_stats()
+ return relations["broken"] and relations["broken"].keys() or []
+
+
+def getStaticCatalogVocabularyQuery(vocabularyFactoryName):
+ factory = queryUtility(IVocabularyFactory, vocabularyFactoryName)
+ if factory:
+ return factory().query
+ return
+
+
+class GetRelations(Service):
+ """Get relations or stats
+
+ source: UID of content item
+ target: UID of content item
+ relation: name of a relation
+ max: integer: maximum of results
+ onlyBroken: boolean: dictionary with broken relations per relation type
+ query_source: Restrict relations by path or SearchableText
+ query_target: Restrict relations by path or SearchableText
+ rebuild: Rebuild relations
+ flush: If rebuild, then this also flushes the intIds
+
+ Returns:
+ stats if no parameter, else relations
+ """
+
+ def __init__(self, context, request):
+ super().__init__(context, request)
+
+ def reply(self):
+ # Disable CSRF protection
+ if "IDisableCSRFProtection" in dir(plone.protect.interfaces):
+ alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection)
+
+ source = self.request.get("source", None)
+ target = self.request.get("target", None)
+ relationship = self.request.get("relation", None)
+ max = self.request.get("max", False)
+ onlyBroken = self.request.get("onlyBroken", False)
+ query_source = self.request.get("query_source", None)
+ query_target = self.request.get("query_target", None)
+
+ targets = None
+ sources = None
+
+ catalog = getToolByName(self.context, "portal_catalog")
+ portal = getSite()
+
+ # Get broken relations for all relation types
+ if onlyBroken:
+ relationNames = getBrokenRelationNames()
+ if len(relationNames) == 0:
+ return self.reply_no_content(status=204)
+ result = {
+ "@id": f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}',
+ "relations": {},
+ }
+ for relationName in relationNames:
+ rels = get_relations(relationship=relationName, onlyBroken=True)
+ result["relations"][relationName] = {
+ "items": rels[relationName],
+ "items_total": len(rels),
+ }
+ return result
+
+ # Stats
+ if not source and not target and not relationship:
+ try:
+ stats = relation_stats()
+ stats[
+ "@id"
+ ] = f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}'
+ return stats
+ except ImportError:
+ self.request.response.setStatus(501)
+ return dict(
+ error=dict(
+ type="ImportError",
+ message="Relationhelpers not available. Install collective.relationhelpers or upgrade to Plone 6!",
+ )
+ )
+ except Unauthorized:
+ return self.reply_no_content(status=401)
+
+ # Query relations
+ if source:
+ if source[0:1] == "/":
+ source = plone_api_content_get(path=source)
+ else:
+ source = plone_api_content_get(UID=source)
+ if not source:
+ return self.reply_no_content(status=404)
+ else:
+ sources = [source]
+
+ if target:
+ if target[0:1] == "/":
+ target = plone_api_content_get(path=target)
+ else:
+ target = plone_api_content_get(UID=target)
+ if not target:
+ return self.reply_no_content(status=404)
+ else:
+ targets = [target]
+
+ if query_source:
+ query_objects = {}
+ if query_source[0] == "/":
+ query_objects["path"] = {"query": f"{portal.id}/{query_source}"}
+ else:
+ query_objects["SearchableText"] = query_source
+ results = catalog.searchResults(**query_objects)
+ sources = [el.getObject() for el in results]
+
+ if query_target:
+ query_objects = {}
+ if query_target[0] == "/":
+ query_objects["path"] = {"query": query_target}
+ else:
+ query_objects["SearchableText"] = query_target
+ results = catalog.searchResults(**query_objects)
+ targets = [el.getObject() for el in results]
+
+ data = get_relations(
+ sources=sources,
+ targets=targets,
+ relationship=relationship,
+ max=max,
+ request=self.request,
+ )
+
+ result = {
+ "@id": f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}',
+ "relations": {},
+ }
+ if relationship and not data:
+ result["relations"][relationship] = {"items": [], "items_total": 0}
+ for key in data:
+ result["relations"][key] = {
+ "items": data[key],
+ "items_total": len(data[key]),
+ }
+ if relationship:
+ scvq = getStaticCatalogVocabularyQuery(relationship)
+ result["relations"][relationship]["staticCatalogVocabularyQuery"] = scvq
+ result["relations"][relationship]["readonly"] = not api_relation_create
+
+ return result
diff --git a/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req
new file mode 100644
index 0000000000..6a930d610d
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req
@@ -0,0 +1,3 @@
+GET /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp
new file mode 100644
index 0000000000..3b60709a66
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp
@@ -0,0 +1,11 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations",
+ "broken": {},
+ "stats": {
+ "comprisesComponentPart": 2,
+ "relatedItems": 2
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del.req b/src/plone/restapi/tests/http-examples/relations_del.req
new file mode 100644
index 0000000000..f7b0ab6376
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del.req
@@ -0,0 +1,14 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "SomeUUID000000000000000000000001",
+ "target": "SomeUUID000000000000000000000002"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del.resp b/src/plone/restapi/tests/http-examples/relations_del.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_del_anonymous.req b/src/plone/restapi/tests/http-examples/relations_del_anonymous.req
new file mode 100644
index 0000000000..f03468b129
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_anonymous.req
@@ -0,0 +1,13 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/document-2"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp
new file mode 100644
index 0000000000..325f535a72
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp
@@ -0,0 +1,7 @@
+HTTP/1.1 401 Unauthorized
+Content-Type: application/json
+
+{
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_combi.req b/src/plone/restapi/tests/http-examples/relations_del_combi.req
new file mode 100644
index 0000000000..109d86fe1c
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_combi.req
@@ -0,0 +1,9 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "relation": "relatedItems",
+ "target": "/document"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_combi.resp b/src/plone/restapi/tests/http-examples/relations_del_combi.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_combi.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_del_failure.req b/src/plone/restapi/tests/http-examples/relations_del_failure.req
new file mode 100644
index 0000000000..03080d1ece
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_failure.req
@@ -0,0 +1,19 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/dont-know-this-doc"
+ },
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/doc-does-not-exist",
+ "target": "/document"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_failure.resp b/src/plone/restapi/tests/http-examples/relations_del_failure.resp
new file mode 100644
index 0000000000..8d686c5e15
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_failure.resp
@@ -0,0 +1,27 @@
+HTTP/1.1 422 Unprocessable Entity
+Content-Type: application/json
+
+{
+ "error": {
+ "failed": [
+ [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/dont-know-this-doc"
+ },
+ "Failed on deleting a relation. Target not found."
+ ],
+ [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/doc-does-not-exist",
+ "target": "/document"
+ },
+ "Failed on deleting a relation. Source not found."
+ ]
+ ],
+ "message": "Failed on deleting relations",
+ "type": "Unprocessable Content"
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_path_uid.req b/src/plone/restapi/tests/http-examples/relations_del_path_uid.req
new file mode 100644
index 0000000000..121f428968
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_path_uid.req
@@ -0,0 +1,19 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/document-2"
+ },
+ {
+ "relation": "comprisesComponentPart",
+ "source": "SomeUUID000000000000000000000001",
+ "target": "SomeUUID000000000000000000000003"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp b/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_del_relationname.req b/src/plone/restapi/tests/http-examples/relations_del_relationname.req
new file mode 100644
index 0000000000..70a218a437
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_relationname.req
@@ -0,0 +1,8 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "relation": "relatedItems"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_relationname.resp b/src/plone/restapi/tests/http-examples/relations_del_relationname.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_relationname.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_del_source.req b/src/plone/restapi/tests/http-examples/relations_del_source.req
new file mode 100644
index 0000000000..af04c18165
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_source.req
@@ -0,0 +1,8 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "source": "/document"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_source.resp b/src/plone/restapi/tests/http-examples/relations_del_source.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_source.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_del_target.req b/src/plone/restapi/tests/http-examples/relations_del_target.req
new file mode 100644
index 0000000000..05632f7333
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_target.req
@@ -0,0 +1,8 @@
+DELETE /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "target": "/document"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_del_target.resp b/src/plone/restapi/tests/http-examples/relations_del_target.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_del_target.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname.req b/src/plone/restapi/tests/http-examples/relations_get_relationname.req
new file mode 100644
index 0000000000..ce27a66f66
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_relationname.req
@@ -0,0 +1,3 @@
+GET /plone/@relations?relation=comprisesComponentPart HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname.resp b/src/plone/restapi/tests/http-examples/relations_get_relationname.resp
new file mode 100644
index 0000000000..2e4ba27cf3
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_relationname.resp
@@ -0,0 +1,55 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?relation=comprisesComponentPart",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ },
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 2,
+ "readonly": false,
+ "staticCatalogVocabularyQuery": null
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req
new file mode 100644
index 0000000000..12ddbaa693
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req
@@ -0,0 +1,2 @@
+GET /plone/@relations?relation=comprisesComponentPart HTTP/1.1
+Accept: application/json
diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp
new file mode 100644
index 0000000000..ebf6c8554f
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp
@@ -0,0 +1,35 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?relation=comprisesComponentPart",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 1,
+ "readonly": false,
+ "staticCatalogVocabularyQuery": null
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req
new file mode 100644
index 0000000000..98c6aa53bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req
@@ -0,0 +1,3 @@
+GET /plone/@relations?source=/document&relation=comprisesComponentPart HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp
new file mode 100644
index 0000000000..fad9ba9423
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp
@@ -0,0 +1,55 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?source=/document&relation=comprisesComponentPart",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ },
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 2,
+ "readonly": false,
+ "staticCatalogVocabularyQuery": null
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req
new file mode 100644
index 0000000000..73075407cb
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req
@@ -0,0 +1,2 @@
+GET /plone/@relations?source=/document HTTP/1.1
+Accept: application/json
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp
new file mode 100644
index 0000000000..c7e0ae18f4
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp
@@ -0,0 +1,33 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?source=/document",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 1
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req
new file mode 100644
index 0000000000..055de329e8
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req
@@ -0,0 +1,3 @@
+GET /plone/@relations?source=/document HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp
new file mode 100644
index 0000000000..9d8937e66c
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp
@@ -0,0 +1,78 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?source=/document",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ },
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 2
+ },
+ "relatedItems": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 1
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req
new file mode 100644
index 0000000000..73921c0fd4
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req
@@ -0,0 +1,3 @@
+GET /plone/@relations?source=SomeUUID000000000000000000000001 HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp
new file mode 100644
index 0000000000..366bd4d066
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp
@@ -0,0 +1,78 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?source=SomeUUID000000000000000000000001",
+ "relations": {
+ "comprisesComponentPart": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ }
+ },
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 2
+ },
+ "relatedItems": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document-3",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000003",
+ "description": "",
+ "review_state": "private",
+ "title": "Test document 3",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 1
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_get_target.req b/src/plone/restapi/tests/http-examples/relations_get_target.req
new file mode 100644
index 0000000000..7ad5065c63
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_target.req
@@ -0,0 +1,3 @@
+GET /plone/@relations?target=/document HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_get_target.resp b/src/plone/restapi/tests/http-examples/relations_get_target.resp
new file mode 100644
index 0000000000..f6fdd7f86a
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_get_target.resp
@@ -0,0 +1,33 @@
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "@id": "http://localhost:55001/plone/@relations?target=/document",
+ "relations": {
+ "relatedItems": {
+ "items": [
+ {
+ "source": {
+ "@id": "http://localhost:55001/plone/document-2",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000002",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 2",
+ "type_title": "Page"
+ },
+ "target": {
+ "@id": "http://localhost:55001/plone/document",
+ "@type": "Document",
+ "UID": "SomeUUID000000000000000000000001",
+ "description": "",
+ "review_state": "published",
+ "title": "Test document 1",
+ "type_title": "Page"
+ }
+ }
+ ],
+ "items_total": 1
+ }
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post.req b/src/plone/restapi/tests/http-examples/relations_post.req
new file mode 100644
index 0000000000..5d796890a6
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post.req
@@ -0,0 +1,19 @@
+POST /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "relatedItems",
+ "source": "/document-3",
+ "target": "/document"
+ },
+ {
+ "relation": "relatedItems",
+ "source": "/document-3",
+ "target": "/document-2"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post.resp b/src/plone/restapi/tests/http-examples/relations_post.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_post_anonyous.req b/src/plone/restapi/tests/http-examples/relations_post_anonyous.req
new file mode 100644
index 0000000000..3ea1ef91a5
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_anonyous.req
@@ -0,0 +1,13 @@
+POST /plone/@relations HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/document-2"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp b/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp
new file mode 100644
index 0000000000..325f535a72
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp
@@ -0,0 +1,7 @@
+HTTP/1.1 401 Unauthorized
+Content-Type: application/json
+
+{
+ "message": "You are not authorized to access this resource.",
+ "type": "Unauthorized"
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post_failure.req b/src/plone/restapi/tests/http-examples/relations_post_failure.req
new file mode 100644
index 0000000000..1dce765542
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_failure.req
@@ -0,0 +1,14 @@
+POST /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/document-does-not-exist"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post_failure.resp b/src/plone/restapi/tests/http-examples/relations_post_failure.resp
new file mode 100644
index 0000000000..276f67cd98
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_failure.resp
@@ -0,0 +1,19 @@
+HTTP/1.1 422 Unprocessable Entity
+Content-Type: application/json
+
+{
+ "error": {
+ "failed": [
+ [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "/document",
+ "target": "/document-does-not-exist"
+ },
+ "Failed on creating a relation. Target not found."
+ ]
+ ],
+ "message": "Failed on creating relations",
+ "type": "Unprocessable Content"
+ }
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post_with_uid.req b/src/plone/restapi/tests/http-examples/relations_post_with_uid.req
new file mode 100644
index 0000000000..75459fdeae
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_with_uid.req
@@ -0,0 +1,19 @@
+POST /plone/@relations HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "items": [
+ {
+ "relation": "comprisesComponentPart",
+ "source": "SomeUUID000000000000000000000001",
+ "target": "SomeUUID000000000000000000000002"
+ },
+ {
+ "relation": "comprisesComponentPart",
+ "source": "SomeUUID000000000000000000000003",
+ "target": "SomeUUID000000000000000000000002"
+ }
+ ]
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp b/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild.req b/src/plone/restapi/tests/http-examples/relations_rebuild.req
new file mode 100644
index 0000000000..f2b6c88b0d
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_rebuild.req
@@ -0,0 +1,3 @@
+POST /plone/@relations/rebuild HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild.resp b/src/plone/restapi/tests/http-examples/relations_rebuild.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_rebuild.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req
new file mode 100644
index 0000000000..9d55e71498
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req
@@ -0,0 +1,8 @@
+POST /plone/@relations/rebuild HTTP/1.1
+Accept: application/json
+Authorization: Basic YWRtaW46c2VjcmV0
+Content-Type: application/json
+
+{
+ "flush": 1
+}
diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp
new file mode 100644
index 0000000000..0074ded3bc
--- /dev/null
+++ b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp
@@ -0,0 +1,2 @@
+HTTP/1.1 204 No Content
+
diff --git a/src/plone/restapi/tests/test_documentation_relations.py b/src/plone/restapi/tests/test_documentation_relations.py
new file mode 100644
index 0000000000..88ee2d07ec
--- /dev/null
+++ b/src/plone/restapi/tests/test_documentation_relations.py
@@ -0,0 +1,552 @@
+from plone import api
+from plone.app.testing import SITE_OWNER_NAME
+from plone.app.testing import SITE_OWNER_PASSWORD
+from plone.app.vocabularies.catalog import StaticCatalogVocabulary
+from plone.restapi.services.relations import api_relation_create
+from plone.restapi.services.relations.get import getStaticCatalogVocabularyQuery
+from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+from plone.restapi.tests.test_documentation import TestDocumentationBase
+from plone.restapi.tests.test_documentation import save_request_and_response_for_docs
+from zope.component import provideUtility
+from zope.schema.interfaces import IVocabularyFactory
+
+import transaction
+
+try:
+ from Products.CMFPlone.relationhelper import rebuild_relations
+except ImportError:
+ try:
+ from collective.relationhelpers.api import rebuild_relations
+ except ImportError:
+ rebuild_relations = None
+
+
+def ExamplesVocabularyFactory(context=None):
+ return StaticCatalogVocabulary(
+ {
+ "portal_type": ["example"],
+ "review_state": "published",
+ "sort_on": "sortable_title",
+ }
+ )
+
+
+class TestRelationsDocumentation(TestDocumentationBase):
+ layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+
+ def setUp(self):
+ super().setUp()
+
+ if api_relation_create:
+ self.doc1 = api.content.create(
+ container=self.portal,
+ type="Document",
+ id="document",
+ title="Test document 1",
+ )
+ api.content.transition(self.doc1, "publish")
+
+ self.doc2 = api.content.create(
+ container=self.portal,
+ type="Document",
+ id="document-2",
+ title="Test document 2",
+ )
+ api.content.transition(self.doc2, "publish")
+
+ self.doc3 = api.content.create(
+ container=self.portal,
+ type="Document",
+ id="document-3",
+ title="Test document 3",
+ )
+
+ transaction.commit()
+
+ api_relation_create(
+ source=self.doc1,
+ target=self.doc2,
+ relationship="comprisesComponentPart",
+ )
+ api_relation_create(
+ source=self.doc1,
+ target=self.doc3,
+ relationship="comprisesComponentPart",
+ )
+ api_relation_create(
+ source=self.doc1,
+ target=self.doc3,
+ relationship="relatedItems",
+ )
+ api_relation_create(
+ source=self.doc2,
+ target=self.doc1,
+ relationship="relatedItems",
+ )
+ transaction.commit()
+
+ def tearDown(self):
+ super().tearDown()
+
+ def test_documentation_GET_relations(self):
+ if api_relation_create:
+ self.assertEqual(
+ set(
+ [
+ relationvalue.to_object
+ for relationvalue in api.relation.get(
+ source=self.doc1, relationship="comprisesComponentPart"
+ )
+ ]
+ ),
+ {self.doc2, self.doc3},
+ )
+ self.assertEqual(
+ set(
+ [
+ relationvalue.to_object
+ for relationvalue in api.relation.get(
+ source=self.doc1, relationship="relatedItems"
+ )
+ ]
+ ),
+ {self.doc3},
+ )
+
+ """
+ Stats of relations
+ """
+ response = self.api_session.get(
+ "/@relations",
+ )
+ save_request_and_response_for_docs("relations_catalog_get_stats", response)
+
+ self.assertEqual(response.status_code, 200)
+ resp = response.json()
+ self.assertIn("stats", resp)
+ self.assertIn("broken", resp)
+ self.assertEqual(resp["stats"]["comprisesComponentPart"], 2)
+ self.assertEqual(resp["broken"], {})
+
+ """
+ Query relations
+ """
+ # relation name
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+ save_request_and_response_for_docs("relations_get_relationname", response)
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 2
+ )
+ self.assertIn(
+ "UID", resp["relations"]["comprisesComponentPart"]["items"][0]["source"]
+ )
+
+ # relation name (sub set of relations for Anonymous)
+ self.api_session.auth = None
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+ save_request_and_response_for_docs(
+ "relations_get_relationname_anonymous", response
+ )
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 1
+ ) # not 2 as for admin
+ self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+ # source by path
+ response = self.api_session.get(
+ "/@relations?source=/document",
+ )
+ save_request_and_response_for_docs("relations_get_source_by_path", response)
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 2
+ )
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1)
+
+ # source by uid
+ response = self.api_session.get(
+ f"/@relations?source={self.doc1.UID()}",
+ )
+ save_request_and_response_for_docs("relations_get_source_by_uid", response)
+
+ # source by path (sub set of relations for Anonymous)
+ self.api_session.auth = None
+ response = self.api_session.get(
+ "/@relations?source=/document",
+ )
+ save_request_and_response_for_docs(
+ "relations_get_source_anonymous", response
+ )
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 1
+ ) # subset of results for manager
+ self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+ # source and relation
+ response = self.api_session.get(
+ "/@relations?source=/document&relation=comprisesComponentPart",
+ )
+ save_request_and_response_for_docs(
+ "relations_get_source_and_relation", response
+ )
+
+ # target
+ response = self.api_session.get(
+ "/@relations?target=/document",
+ )
+ save_request_and_response_for_docs("relations_get_target", response)
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1)
+
+ def test_documentation_GET_relations_vocabulary(self):
+ if api_relation_create:
+ # Register named staticCatalogVocabulary
+ factory = ExamplesVocabularyFactory # ()
+ provideUtility(
+ factory, provides=IVocabularyFactory, name="comprisesComponentPart"
+ )
+
+ # Query relations
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+
+ resp = response.json()
+ # Is the vocabulary registered?
+ self.assertEqual(
+ getStaticCatalogVocabularyQuery("comprisesComponentPart"),
+ {
+ "portal_type": ["example"],
+ "review_state": "published",
+ "sort_on": "sortable_title",
+ },
+ )
+ # Is the vocabulary included in response?
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"][
+ "staticCatalogVocabularyQuery"
+ ],
+ {
+ "portal_type": ["example"],
+ "review_state": "published",
+ "sort_on": "sortable_title",
+ },
+ )
+
+ def test_documentation_POST_relations(self):
+ """
+ Add relations
+ """
+ self.maxDiff = None
+
+ if api_relation_create:
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 2)
+
+ response = self.api_session.post(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document-3",
+ "target": "/document",
+ "relation": "relatedItems",
+ },
+ {
+ "source": "/document-3",
+ "target": "/document-2",
+ "relation": "relatedItems",
+ },
+ ]
+ },
+ )
+ save_request_and_response_for_docs("relations_post", response)
+
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 4)
+
+ # Failing addition
+ response = self.api_session.post(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document",
+ "target": "/document-does-not-exist",
+ "relation": "comprisesComponentPart",
+ }
+ ]
+ },
+ )
+ save_request_and_response_for_docs("relations_post_failure", response)
+ resp = response.json()
+ self.assertIn("failed", resp["error"])
+
+ # Add by UID
+ response = self.api_session.post(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": self.doc1.UID(),
+ "target": self.doc2.UID(),
+ "relation": "comprisesComponentPart",
+ },
+ {
+ "source": self.doc3.UID(),
+ "target": self.doc2.UID(),
+ "relation": "comprisesComponentPart",
+ },
+ ]
+ },
+ )
+ save_request_and_response_for_docs("relations_post_with_uid", response)
+
+ def test_documentation_POST_relations_anonymous(self):
+ """
+ Post relations
+ """
+
+ if api_relation_create:
+ self.api_session.auth = None
+ response = self.api_session.post(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document",
+ "target": "/document-2",
+ "relation": "comprisesComponentPart",
+ }
+ ]
+ },
+ )
+ self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+ save_request_and_response_for_docs("relations_post_anonyous", response)
+
+ # Get relations and test that no relation is removed.
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 2
+ )
+
+ def test_documentation_DEL_relations_list(self):
+ """
+ Delete relations
+ """
+ self.maxDiff = None
+
+ if api_relation_create:
+ # Delete list by path and UID
+ response = self.api_session.delete(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document",
+ "target": "/document-2",
+ "relation": "comprisesComponentPart",
+ },
+ {
+ "source": self.doc1.UID(),
+ "target": self.doc3.UID(),
+ "relation": "comprisesComponentPart",
+ },
+ ]
+ },
+ )
+ save_request_and_response_for_docs("relations_del_path_uid", response)
+
+ # Get relations and test that the deleted relations are removed.
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+ resp = response.json()
+
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 0
+ ) # instead of 2 before deletion
+
+ # Failing deletion
+ response = self.api_session.delete(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document",
+ "target": "/dont-know-this-doc",
+ "relation": "comprisesComponentPart",
+ },
+ {
+ "source": "/doc-does-not-exist",
+ "target": "/document",
+ "relation": "comprisesComponentPart",
+ },
+ ]
+ },
+ )
+ save_request_and_response_for_docs("relations_del_failure", response)
+ resp = response.json()
+ self.assertIn("error", resp)
+
+ def test_documentation_DEL_relations_list_anonymous(self):
+ """
+ Delete relations
+ """
+
+ if api_relation_create:
+ self.api_session.auth = None
+ response = self.api_session.delete(
+ "/@relations",
+ json={
+ "items": [
+ {
+ "source": "/document",
+ "target": "/document-2",
+ "relation": "comprisesComponentPart",
+ }
+ ]
+ },
+ )
+ self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD)
+
+ save_request_and_response_for_docs("relations_del_anonymous", response)
+
+ # Get relations and test that no relation is removed.
+ response = self.api_session.get(
+ "/@relations?relation=comprisesComponentPart",
+ )
+ resp = response.json()
+ self.assertEqual(
+ resp["relations"]["comprisesComponentPart"]["items_total"], 2
+ )
+
+ def test_documentation_DEL_relations_by_relationship(self):
+ """
+ Delete relations
+ """
+ self.maxDiff = None
+
+ if api_relation_create:
+
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 2)
+
+ # Delete by relation name
+ response = self.api_session.delete(
+ "/@relations",
+ json={"relation": "relatedItems"},
+ )
+ save_request_and_response_for_docs("relations_del_relationname", response)
+
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 0)
+
+ def test_documentation_DEL_relations_by_source_or_target(self):
+ """
+ Delete relations
+ """
+ self.maxDiff = None
+
+ if api_relation_create:
+ # Delete by source
+ response = self.api_session.delete(
+ "/@relations",
+ json={"source": "/document"},
+ )
+ save_request_and_response_for_docs("relations_del_source", response)
+
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1)
+
+ # Delete by target
+ response = self.api_session.delete(
+ "/@relations",
+ json={"target": "/document"},
+ )
+ save_request_and_response_for_docs("relations_del_target", response)
+
+ response = self.api_session.get(
+ "/@relations?relation=relatedItems",
+ )
+ resp = response.json()
+ self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 0)
+
+ def test_documentation_DEL_relations_bunch_combi(self):
+ """
+ Delete relations
+ """
+ self.maxDiff = None
+
+ if api_relation_create:
+ response = self.api_session.get(
+ "/@relations",
+ )
+ resp = response.json()
+ self.assertEqual(resp["stats"]["comprisesComponentPart"], 2)
+ self.assertEqual(resp["stats"]["relatedItems"], 2)
+
+ # Delete by combination of source and relation name
+ response = self.api_session.delete(
+ "/@relations",
+ json={"source": "/document", "relation": "relatedItems"},
+ )
+ save_request_and_response_for_docs("relations_del_combi", response)
+
+ response = self.api_session.get(
+ "/@relations",
+ )
+ resp = response.json()
+ self.assertEqual(resp["stats"]["comprisesComponentPart"], 2)
+ self.assertEqual(resp["stats"]["relatedItems"], 1)
+
+ # Delete by combination of target and relation name
+ response = self.api_session.delete(
+ "/@relations",
+ json={"target": "/document", "relation": "relatedItems"},
+ )
+ save_request_and_response_for_docs("relations_del_combi", response)
+
+ response = self.api_session.get(
+ "/@relations",
+ )
+ resp = response.json()
+ self.assertEqual(resp["stats"]["comprisesComponentPart"], 2)
+ self.assertNotIn("relatedItems", resp["stats"])
+
+ def test_documentation_POST_rebuild(self):
+ if rebuild_relations:
+ response = self.api_session.post("/@relations/rebuild")
+ save_request_and_response_for_docs("relations_rebuild", response)
+
+ response = self.api_session.post(
+ "/@relations/rebuild",
+ json={"flush": 1},
+ )
+ save_request_and_response_for_docs("relations_rebuild_with_flush", response)