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

Issue:
Implement IPurgePaths for ++api++ urls
When `plone.app.caching` is installed and setup, purging the cache should also purge `plone.restapi` endpoints that use seamless mode (++api++ traversal)


Discussion / hints:

Fix patch:
diff --git a/news/1587.feature b/news/1587.feature
new file mode 100644
index 0000000000..682a36ba78
--- /dev/null
+++ b/news/1587.feature
@@ -0,0 +1,1 @@
+Implement IPurgePaths for RestAPI traversal (++api++) @ericof
\ No newline at end of file
diff --git a/src/plone/restapi/cache/__init__.py b/src/plone/restapi/cache/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/plone/restapi/cache/configure.zcml b/src/plone/restapi/cache/configure.zcml
new file mode 100644
index 0000000000..3a5e47d4c7
--- /dev/null
+++ b/src/plone/restapi/cache/configure.zcml
@@ -0,0 +1,13 @@
+<configure
+    xmlns="http://namespaces.zope.org/zope"
+    xmlns:zcml="http://namespaces.zope.org/zcml"
+    >
+
+  <!-- Paths to purge -->
+  <adapter
+      factory=".paths.RestAPIPurgePaths"
+      name="plone.restapi"
+      zcml:condition="have plone-app-caching-3"
+      />
+
+</configure>
diff --git a/src/plone/restapi/cache/paths.py b/src/plone/restapi/cache/paths.py
new file mode 100644
index 0000000000..dc7b7ab8f3
--- /dev/null
+++ b/src/plone/restapi/cache/paths.py
@@ -0,0 +1,69 @@
+from Acquisition import aq_parent
+from plone.dexterity.content import DexterityContent
+from plone.dexterity.interfaces import IDexterityContent
+from typing import List
+from z3c.caching.interfaces import IPurgePaths
+from zope.component import adapter
+from zope.interface import implementer
+
+
+CONTEXT_ENDPOINTS = [
+    "/@actions",
+    "/@breadcrumbs",
+    "/@comments",
+    "/@navigation",
+]
+
+
+PARENT_ENDPOINTS = [
+    "/@navigation",
+]
+
+
+@implementer(IPurgePaths)
+@adapter(IDexterityContent)
+class RestAPIPurgePaths:
+    """RestAPI paths to purge for content items."""
+
+    TRAVERSAL_PREFIX: str = "/++api++"
+
+    def __init__(self, context: DexterityContent):
+        """Initialize RestAPIPurgePaths."""
+        self.context = context
+
+    def getRelativePaths(self) -> List[str]:
+        """Return a list of paths that should be purged. The paths should be
+        relative to the virtual hosting root, i.e. they should start with a
+        '/'.
+
+        These paths will be rewritten to incorporate virtual hosting if
+        necessary.
+        """
+        prefix = self.TRAVERSAL_PREFIX
+        base_path = f"/{self.context.virtual_url_path()}"
+        paths = [f"{prefix}{base_path}"]
+
+        # Add service endpoints for content
+        for endpoint in CONTEXT_ENDPOINTS:
+            path = f"{prefix}{base_path}{endpoint}"
+            paths.append(path)
+
+        # Process parent context
+        parent = aq_parent(self.context)
+        if parent is None:
+            return paths
+
+        base_path = f"/{parent.virtual_url_path()}"
+        # Add service endpoints for parent
+        for endpoint in PARENT_ENDPOINTS:
+            path = f"{prefix}{base_path}{endpoint}".replace("//", "/")
+            paths.append(path)
+        return paths
+
+    def getAbsolutePaths(self) -> List[str]:
+        """Return a list of paths that should be purged. The paths should be
+        relative to the domain root, i.e. they should start with a '/'.
+
+        These paths will *not* be rewritten to incorporate virtual hosting.
+        """
+        return []
diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml
index 5b1c557256..e3202531ef 100644
--- a/src/plone/restapi/configure.zcml
+++ b/src/plone/restapi/configure.zcml
@@ -97,6 +97,7 @@
   <include package=".deserializer" />
   <include package=".types" />
   <include package=".search" />
+  <include package=".cache" />
 
   <include package=".upgrades" />
 


Test patch:
diff --git a/src/plone/restapi/tests/test_cache_purge_urls.py b/src/plone/restapi/tests/test_cache_purge_urls.py
new file mode 100644
index 0000000000..6425f4c9b4
--- /dev/null
+++ b/src/plone/restapi/tests/test_cache_purge_urls.py
@@ -0,0 +1,76 @@
+from Acquisition import aq_base
+from Acquisition import Explicit
+from plone.restapi.cache.paths import RestAPIPurgePaths
+from plone.testing.zca import UNIT_TESTING
+from Products.CMFCore.interfaces import IContentish
+from Products.CMFDynamicViewFTI.interfaces import IBrowserDefault
+from zope.interface import implementer
+
+import unittest
+
+
+@implementer(IContentish)
+class FauxNonContent(Explicit):
+    def __init__(self, name=None):
+        self.__name__ = name
+        self.__parent__ = None  # may be overridden by acquisition
+
+    def getId(self):
+        return self.__name__
+
+    def virtual_url_path(self):
+        parent = aq_base(self.__parent__)
+        if parent is not None:
+            return f"{parent.virtual_url_path()}/{self.__name__}"
+        else:
+            return self.__name__
+
+    def getPhysicalPath(self):
+        return ("",)
+
+    def getParentNode(self):
+        return FauxNonContent("folder")
+
+
+@implementer(IBrowserDefault)
+class FauxContent(FauxNonContent):
+    portal_type = "testtype"
+
+    def defaultView(self):
+        return "default-view"
+
+
+class TestRestAPIPurgePaths(unittest.TestCase):
+    layer = UNIT_TESTING
+
+    def test_no_parent(self):
+        context = FauxNonContent("foo")
+        purger = RestAPIPurgePaths(context)
+
+        self.assertEqual(
+            [
+                "/++api++/foo",
+                "/++api++/foo/@actions",
+                "/++api++/foo/@breadcrumbs",
+                "/++api++/foo/@comments",
+                "/++api++/foo/@navigation",
+            ],
+            list(purger.getRelativePaths()),
+        )
+        self.assertEqual([], list(purger.getAbsolutePaths()))
+
+    def test_parent(self):
+        context = FauxContent("foo").__of__(FauxContent("bar"))
+        purger = RestAPIPurgePaths(context)
+        self.assertEqual(
+            [
+                "/++api++/bar/foo",
+                "/++api++/bar/foo/@actions",
+                "/++api++/bar/foo/@breadcrumbs",
+                "/++api++/bar/foo/@comments",
+                "/++api++/bar/foo/@navigation",
+                "/++api++/bar/@navigation",
+            ],
+            list(purger.getRelativePaths()),
+        )
+        self.assertEqual([], list(purger.getAbsolutePaths()))