Repository: plone/plone.restapi (language: python)

Issue:
linkintegrity documentation missing a response file
`plone.restapi/src/plone/restapi/tests/http-examples/linkintegrity_get.resp` needs content.

See https://6.docs.plone.org/plone.restapi/docs/source/endpoints/linkintegrity.html


Discussion / hints:

Fix patch:
diff --git a/docs/source/endpoints/linkintegrity.md b/docs/source/endpoints/linkintegrity.md
index a6bff085b1..26d54c64b5 100644
--- a/docs/source/endpoints/linkintegrity.md
+++ b/docs/source/endpoints/linkintegrity.md
@@ -13,25 +13,34 @@ When you create relations between content objects in Plone (for example, via rel
 The Plone user interface will use those stored relations to show a warning when you try to delete a content object that is still referenced elsewhere.
 Link integrity avoids broken links ("breaches") in the site.
 
-This check includes content objects that are located within a content object ("folderish content").
+The `@linkintegrity` endpoint returns the list of reference breaches that would happen if some content items would be deleted.
+This information can be used to show the editor a confirmation dialog.
 
-The `@linkintegrity` endpoint returns the list of reference breaches.
-If there are none, it will return an empty list (`[]`).
+This check includes content objects that are located within a content object ("folderish content").
 
-You can call the `/@linkintegrity` endpoint on the site root with a `GET` request and a list of UIDs in the JSON body:
+You can call the `/@linkintegrity` endpoint on the site root with a `GET` request and a list of content UIDs in the JSON body:
 
 ```{eval-rst}
 ..  http:example:: curl httpie python-requests
     :request: ../../../src/plone/restapi/tests/http-examples/linkintegrity_get.req
 ```
 
+The endpoint accepts a single parameter:
+
+`uids`
+: A list of object UIDs that you want to check.
+
 The server will respond with the result:
 
 ```{literalinclude} ../../../src/plone/restapi/tests/http-examples/linkintegrity_get.resp
    :language: http
 ```
 
-The endpoint accepts a single parameter:
+The result includes a list of objects corresponding to the UIDs that were requested.
+Each result object includes:
 
-`uids`
-: A list of object UIDs that you want to check.
+`breaches`
+: A list of breaches (sources of relations that would be broken)
+
+`items_total`
+: Count of items contained inside the specified UIDs.
diff --git a/docs/source/upgrade-guide.md b/docs/source/upgrade-guide.md
index 2f12c1e8c4..da73d71c7a 100644
--- a/docs/source/upgrade-guide.md
+++ b/docs/source/upgrade-guide.md
@@ -12,6 +12,31 @@ myst:
 This upgrade guide lists all breaking changes in `plone.restapi`.
 It explains the steps that are needed to upgrade to the latest version.
 
+## Upgrading to `plone.restapi` 9.x
+
+### Link Integrity
+
+When calling the @linkintegrity endpoint in `plone.restapi` before 9.0.0, a content object with no link integrity breaches would return just an empty list in the response body:
+
+`[]`
+
+In `plone.restapi` 9.0.0, the following response would be returned with a `breaches` attribute with an empty list:
+
+```
+[
+    {
+        "@id": "http://localhost:55001/plone/doc-2",
+        "@type": "Document",
+        "breaches": [],
+        "description": "",
+        "items_total": 0,
+        "review_state": "private",
+        "title": "Second document",
+        "type_title": "Page"
+    }
+]
+```
+
 ## Upgrading to `plone.restapi` 8.x
 
 `plone.restapi` 8.x dropped support for Python 2 and Plone 5.1 and 4.3.
diff --git a/news/1636.breaking b/news/1636.breaking
new file mode 100644
index 0000000000..58a49720c9
--- /dev/null
+++ b/news/1636.breaking
@@ -0,0 +1,1 @@
+Change the @linkintegrity endpoint to add `items_total`, the number of contained items which would be deleted. @davisagli, @danalvrz, @pgrunewald
diff --git a/src/plone/restapi/services/linkintegrity/get.py b/src/plone/restapi/services/linkintegrity/get.py
index 41edeae4ca..6c6aa7ea05 100644
--- a/src/plone/restapi/services/linkintegrity/get.py
+++ b/src/plone/restapi/services/linkintegrity/get.py
@@ -4,6 +4,7 @@
 from plone.restapi.interfaces import ISerializeToJsonSummary
 from plone.restapi.serializer.converters import json_compatible
 from plone.restapi.services import Service
+from Products.CMFCore.utils import getToolByName
 from zExceptions import BadRequest
 from zope.component import getMultiAdapter
 from zope.interface import implementer
@@ -27,13 +28,13 @@ def reply(self):
         if not isinstance(uids, list):
             uids = [uids]
 
+        catalog = getToolByName(self.context, "portal_catalog")
         result = []
         for uid in uids:
             item = uuidToObject(uid)
+            item_path = "/".join(item.getPhysicalPath())
             links_info = item.restrictedTraverse("@@delete_confirmation_info")
             breaches = links_info.get_breaches()
-            if not breaches:
-                continue
             data = getMultiAdapter((item, self.request), ISerializeToJsonSummary)()
             data["breaches"] = []
             for breach in breaches:
@@ -43,5 +44,7 @@ def reply(self):
                     del source["url"]
                     del source["accessible"]
                     data["breaches"].append(source)
+            # subtract one because we don't want to count item_path itself
+            data["items_total"] = len(catalog(path=item_path)) - 1
             result.append(data)
         return json_compatible(result)


Test patch:
diff --git a/src/plone/restapi/tests/http-examples/linkintegrity_get.req b/src/plone/restapi/tests/http-examples/linkintegrity_get.req
index cb79c1d528..543a107b7a 100644
--- a/src/plone/restapi/tests/http-examples/linkintegrity_get.req
+++ b/src/plone/restapi/tests/http-examples/linkintegrity_get.req
@@ -1,3 +1,3 @@
-GET /plone/@linkintegrity?uids=SomeUUID000000000000000000000001 HTTP/1.1
+GET /plone/@linkintegrity?uids=SomeUUID000000000000000000000002 HTTP/1.1
 Accept: application/json
 Authorization: Basic YWRtaW46c2VjcmV0
diff --git a/src/plone/restapi/tests/http-examples/linkintegrity_get.resp b/src/plone/restapi/tests/http-examples/linkintegrity_get.resp
index 30404ce4c5..338c0815c4 100644
--- a/src/plone/restapi/tests/http-examples/linkintegrity_get.resp
+++ b/src/plone/restapi/tests/http-examples/linkintegrity_get.resp
@@ -1,1 +1,21 @@
-TODO
\ No newline at end of file
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+[
+    {
+        "@id": "http://localhost:55001/plone/doc-2",
+        "@type": "Document",
+        "breaches": [
+            {
+                "@id": "http://localhost:55001/plone/doc-1",
+                "title": "First document",
+                "uid": "SomeUUID000000000000000000000001"
+            }
+        ],
+        "description": "",
+        "items_total": 0,
+        "review_state": "private",
+        "title": "Second document",
+        "type_title": "Page"
+    }
+]
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index 6f4a62329d..1634fc3806 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -28,14 +28,18 @@
 from plone.restapi.tests.statictime import StaticTime
 from plone.testing.zope import Browser
 from plone.uuid.interfaces import IUUID
+from z3c.relationfield import RelationValue
 from zope.component import createObject
 from zope.component import getMultiAdapter
 from zope.component import getUtility
+from zope.component.hooks import getSite
+from zope.event import notify
 from zope.interface import alsoProvides
+from zope.intid.interfaces import IIntIds
+from zope.lifecycleevent import ObjectModifiedEvent
 from plone.app.testing import popGlobalRegistry
 from plone.app.testing import pushGlobalRegistry
 from plone.restapi.testing import register_static_uuid_utility
-from zope.component.hooks import getSite
 
 import collections
 import json
@@ -2853,3 +2857,27 @@ def test_controlpanels_crud_rules(self):
         url = "/@controlpanels/content-rules/rule-3"
         response = self.api_session.delete(url)
         save_request_and_response_for_docs("controlpanels_delete_rule", response)
+
+
+class TestLinkintegrity(TestDocumentationBase):
+
+    layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+
+    def setUp(self):
+        super().setUp()
+
+        # Create one document with a reference to another
+        self.doc1 = createContentInContainer(
+            self.portal, "Document", id="doc-1", title="First document"
+        )
+        self.doc2 = createContentInContainer(
+            self.portal, "Document", id="doc-2", title="Second document"
+        )
+        intids = getUtility(IIntIds)
+        self.doc1.relatedItems = [RelationValue(intids.getId(self.doc2))]
+        notify(ObjectModifiedEvent(self.doc1))
+        transaction.commit()
+
+    def test_linkintegrity_get(self):
+        response = self.api_session.get("/@linkintegrity?uids=" + self.doc2.UID())
+        save_request_and_response_for_docs("linkintegrity_get", response)
diff --git a/src/plone/restapi/tests/test_services_linkintegrity.py b/src/plone/restapi/tests/test_services_linkintegrity.py
index 957eb93e2e..297843f709 100644
--- a/src/plone/restapi/tests/test_services_linkintegrity.py
+++ b/src/plone/restapi/tests/test_services_linkintegrity.py
@@ -49,12 +49,13 @@ def test_required_uids(self):
 
         self.assertEqual(response.status_code, 400)
 
-    def test_return_empty_list_for_non_referenced_objects(self):
+    def test_return_no_breaches_for_non_referenced_objects(self):
         response = self.api_session.get(
             "/@linkintegrity", params={"uids": [self.doc1.UID()]}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), [])
+        self.assertEqual(len(response.json()), 1)
+        self.assertEqual(response.json()[0]["breaches"], [])
 
     def test_return_right_breaches_for_reference_field(self):
         intids = getUtility(IIntIds)
@@ -95,8 +96,9 @@ def test_return_right_breaches_for_blocks(self):
         response = self.api_session.get(
             "/@linkintegrity", params={"uids": [self.doc2.UID()]}
         )
-        breaches = response.json()
-        self.assertEqual(breaches, [])
+        result = response.json()
+        self.assertEqual(len(result), 1)
+        self.assertEqual(result[0]["breaches"], [])
 
         # create a new content with relations
         uid = IUUID(self.doc2)
@@ -188,3 +190,23 @@ def test_return_breaches_for_contents_in_subfolders(self):
         self.assertEqual(len(breaches), 1)
         self.assertEqual(breaches[0]["uid"], IUUID(doc_in_folder))
         self.assertEqual(breaches[0]["@id"], doc_in_folder.absolute_url())
+
+    def test_return_items_total_in_subfolders(self):
+        # create a folder structure
+        level1 = createContentInContainer(self.portal, "Folder", id="level1")
+        createContentInContainer(self.portal["level1"], "Folder", id="level2")
+        transaction.commit()
+
+        # get linkintegrity info for the folder
+        response = self.api_session.get(
+            "/@linkintegrity", params={"uids": [level1.UID()]}
+        )
+
+        # we don't expect any links but we still want information
+        # about how many contained items will be deleted
+        result = response.json()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(result), 1)
+        self.assertEqual(result[0]["@id"], level1.absolute_url())
+        self.assertEqual(result[0]["breaches"], [])
+        self.assertEqual(result[0]["items_total"], 1)