Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/source/endpoints/querystringsearch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/source/endpoints/searching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions news/2023.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix Virtual Host Monster (VHM) path resolution in @querystring-search endpoint.
31 changes: 31 additions & 0 deletions src/plone/restapi/services/querystringsearch/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
193 changes: 193 additions & 0 deletions src/plone/restapi/tests/test_services_querystringsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")