diff --git a/docs/source/endpoints/querystringsearch.md b/docs/source/endpoints/querystringsearch.md index e56f49200..f94742bfd 100644 --- a/docs/source/endpoints/querystringsearch.md +++ b/docs/source/endpoints/querystringsearch.md @@ -39,6 +39,14 @@ The server will respond with the results that are filtered based on the query yo :language: http ``` +### Virtual Host Monster Support + +When accessed through a Virtual Host Monster (VHM), the endpoint automatically resolves virtual paths provided in the `query` criteria to their physical counterparts in the catalog. + +For example, if your Plone site is physically located at `/Plone` but served at `http://plone.org/`, a query for `v: "/folder"` will be automatically expanded to `/Plone/folder` before being passed to the catalog. + +This expansion applies to any value in a `path` criterion that starts with a `/`. It also correctly handles the `::depth` suffix. + Parameters the endpoint will accept: - `query` - `plone.app.querystring` query, required diff --git a/docs/source/endpoints/searching.md b/docs/source/endpoints/searching.md index c3041d474..83fc218b8 100644 --- a/docs/source/endpoints/searching.md +++ b/docs/source/endpoints/searching.md @@ -204,6 +204,14 @@ Be aware that this might induce performance issues when retrieving a lot of reso Normally the search just serializes catalog brains, but with `fullobjects`, we wake up all the returned objects. ``` +## Virtual Host Monster Support + +When accessed through a Virtual Host Monster (VHM), the `/@search` endpoint automatically resolves virtual paths provided in the `path` parameter to their physical counterparts in the catalog. + +For example, if your Plone site is physically located at `/Plone` but served at `http://plone.org/`, a query for `path=/folder` will be automatically expanded to `/Plone/folder` before being passed to the catalog. + +This expansion applies to both string and list values for the `path` parameter, provided they start with a `/`. + ## Restrict search results to Plone's search settings By default, the search endpoint does not exclude any types from its results. diff --git a/news/2023.bugfix b/news/2023.bugfix new file mode 100644 index 000000000..ec4cb61e7 --- /dev/null +++ b/news/2023.bugfix @@ -0,0 +1 @@ +Fix Virtual Host Monster (VHM) path resolution in @querystring-search endpoint. diff --git a/src/plone/restapi/services/querystringsearch/get.py b/src/plone/restapi/services/querystringsearch/get.py index 25be01801..8ccc9f2f8 100644 --- a/src/plone/restapi/services/querystringsearch/get.py +++ b/src/plone/restapi/services/querystringsearch/get.py @@ -50,6 +50,37 @@ def __call__(self): if not query: raise BadRequest("No query supplied") + # If this is accessed through a VHM the client does not know + # the complete physical path of an object. But the path index + # indexes the complete physical path. Complete the path. + vhm_physical_path = self.request.get("VirtualRootPhysicalPath") + if vhm_physical_path: + for criterion in query: + if criterion.get("i") == "path": + v = criterion.get("v") + if isinstance(v, str) and v.startswith("/"): + path_parts = v.split("::", 1) + path = path_parts[0].lstrip("/") + full_path = "/".join(vhm_physical_path + (path,)) + if len(path_parts) > 1: + criterion["v"] = f"{full_path}::{path_parts[1]}" + else: + criterion["v"] = full_path + elif isinstance(v, list): + new_v = [] + for p in v: + if isinstance(p, str) and p.startswith("/"): + path_parts = p.split("::", 1) + path = path_parts[0].lstrip("/") + full_path = "/".join(vhm_physical_path + (path,)) + if len(path_parts) > 1: + new_v.append(f"{full_path}::{path_parts[1]}") + else: + new_v.append(full_path) + else: + new_v.append(p) + criterion["v"] = new_v + if sort_order: sort_order = "descending" if sort_order == "descending" else "ascending" diff --git a/src/plone/restapi/tests/test_services_querystringsearch.py b/src/plone/restapi/tests/test_services_querystringsearch.py index a64d545e6..c00a1c419 100644 --- a/src/plone/restapi/tests/test_services_querystringsearch.py +++ b/src/plone/restapi/tests/test_services_querystringsearch.py @@ -4,6 +4,7 @@ from plone.app.testing import TEST_USER_ID from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING from plone.restapi.testing import RelativeSession +from plone.restapi.tests.helpers import result_paths from unittest import mock import transaction @@ -431,3 +432,195 @@ def mock_serializer(self): f"Expected {num_results} results, got {response.json()['items_total']}. " "QuerystringSearch should not impose a default limit.", ) + + +class TestQuerystringSearchVHM(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url, test=self) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + self.portal.invokeFactory("Folder", "folder", title="Folder") + self.portal.folder.invokeFactory("Document", "doc1", title="Document 1") + transaction.commit() + + def tearDown(self): + self.api_session.close() + + def test_querystringsearch_vhm_path(self): + # Install a Virtual Host Monster + if "virtual_hosting" not in self.app.objectIds(): + from Products.SiteAccess.VirtualHostMonster import ( + manage_addVirtualHostMonster, + ) + + manage_addVirtualHostMonster(self.app, "virtual_hosting") + transaction.commit() + + # If we go through the VHM we will get results if we only use + # the part of the path inside the VHM + vhm_url = "{}/VirtualHostBase/http/plone.org/plone/VirtualHostRoot/{}".format( + self.app.absolute_url(), + "@querystring-search", + ) + + # Test with string path + response = self.api_session.post( + vhm_url, + json={ + "query": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/folder::1", + } + ] + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("/folder/doc1", result_paths(response.json())) + + def test_querystringsearch_vhm_multiple_paths(self): + # Install a Virtual Host Monster + if "virtual_hosting" not in self.app.objectIds(): + from Products.SiteAccess.VirtualHostMonster import ( + manage_addVirtualHostMonster, + ) + + manage_addVirtualHostMonster(self.app, "virtual_hosting") + + self.portal.invokeFactory("Folder", "folder2", title="Folder 2") + self.portal.folder2.invokeFactory("Document", "doc2", title="Document 2") + transaction.commit() + + vhm_url = "{}/VirtualHostBase/http/plone.org/plone/VirtualHostRoot/{}".format( + self.app.absolute_url(), + "@querystring-search", + ) + + # Test with multiple path criteria + response = self.api_session.post( + vhm_url, + json={ + "query": [ + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/folder", + }, + { + "i": "path", + "o": "plone.app.querystring.operation.string.path", + "v": "/folder2", + }, + ] + }, + ) + + self.assertEqual(response.status_code, 200) + paths = result_paths(response.json()) + self.assertIn("/folder", paths) + self.assertIn("/folder2", paths) + + +class TestVHMPathCorrectionUnit(unittest.TestCase): + + def test_path_correction_string(self): + from plone.restapi.services.querystringsearch.get import QuerystringSearch + + request = mock.Mock() + request.get.return_value = ("", "Plone", "en") + # Mock json_body to return our query + with mock.patch( + "plone.restapi.services.querystringsearch.get.json_body" + ) as mock_json_body: + mock_json_body.return_value = { + "query": [{"i": "path", "o": "op", "v": "/folder::1"}] + } + context = mock.Mock() + service = QuerystringSearch(context, request) + + # We only want to test the path correction part, so we'll mock the rest of __call__ + # by causing it to fail after the correction + with mock.patch( + "plone.restapi.services.querystringsearch.get.getMultiAdapter" + ) as mock_adapter: + mock_adapter.side_effect = Exception("Stop here") + try: + service() + except Exception as e: + if str(e) != "Stop here": + raise + + # Check if query was modified in place (since it's a list of dicts) + query = mock_json_body.return_value["query"] + self.assertEqual(query[0]["v"], "/Plone/en/folder::1") + + def test_path_correction_list(self): + from plone.restapi.services.querystringsearch.get import QuerystringSearch + + request = mock.Mock() + request.get.return_value = ("", "Plone", "en") + with mock.patch( + "plone.restapi.services.querystringsearch.get.json_body" + ) as mock_json_body: + mock_json_body.return_value = { + "query": [ + { + "i": "path", + "o": "op", + "v": ["/folder1", "/folder2::2", "some-uid"], + } + ] + } + context = mock.Mock() + service = QuerystringSearch(context, request) + + with mock.patch( + "plone.restapi.services.querystringsearch.get.getMultiAdapter" + ) as mock_adapter: + mock_adapter.side_effect = Exception("Stop here") + try: + service() + except Exception: + pass + + query = mock_json_body.return_value["query"] + self.assertEqual( + query[0]["v"], ["/Plone/en/folder1", "/Plone/en/folder2::2", "some-uid"] + ) + + def test_no_path_correction_without_vhm(self): + from plone.restapi.services.querystringsearch.get import QuerystringSearch + + request = mock.Mock() + request.get.return_value = None + with mock.patch( + "plone.restapi.services.querystringsearch.get.json_body" + ) as mock_json_body: + mock_json_body.return_value = { + "query": [{"i": "path", "o": "op", "v": "/folder::1"}] + } + context = mock.Mock() + service = QuerystringSearch(context, request) + + with mock.patch( + "plone.restapi.services.querystringsearch.get.getMultiAdapter" + ) as mock_adapter: + mock_adapter.side_effect = Exception("Stop here") + try: + service() + except Exception: + pass + + query = mock_json_body.return_value["query"] + self.assertEqual(query[0]["v"], "/folder::1")