diff --git a/docs/backend/SEARCH_API_MIGRATION.md b/docs/backend/SEARCH_API_MIGRATION.md
new file mode 100644
index 000000000000..071d52d83f55
--- /dev/null
+++ b/docs/backend/SEARCH_API_MIGRATION.md
@@ -0,0 +1,215 @@
+# Search API Migration Guide
+
+This guide is intended for **dotCMS plugin and integration developers** who use the
+`ContentletAPI`, `ESSeachAPI`, or the `$ESContent` Velocity tool in their extensions.
+
+The changes described here are part of the ongoing ES → OpenSearch migration. The
+deprecated methods listed below **will be removed** when dotCMS completes the cutover
+to OpenSearch. Migrate before that happens to avoid compilation failures in your plugins.
+
+---
+
+## 1. `ContentletAPI` — deprecated search methods
+
+### What was deprecated
+
+| Method | Return type | Status |
+|--------|-------------|--------|
+| `esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `ESSearchResults` | `@Deprecated(forRemoval = true)` |
+| `esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `SearchResponse` | `@Deprecated(forRemoval = true)` |
+
+Both methods delegate to `ESSeachAPI` directly and return Elasticsearch-specific types
+(`ESSearchResults`, `org.elasticsearch.action.search.SearchResponse`). They will be
+removed at OS cutover.
+
+### Replacements
+
+| Old method | New method | New return type |
+|------------|------------|-----------------|
+| `esSearch(...)` | `search(String query, boolean live, User user, boolean respectFrontendRoles)` | `ContentSearchResults` |
+| `esSearchRaw(...)` | `searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` | `ContentSearchResponse` |
+
+The new methods route through the phase-aware `SearchAPI` router (ES in phases 0–1,
+OpenSearch in phases 2–3) and return vendor-neutral DTOs.
+
+### Migration example
+
+```java
+// Before
+ContentletAPI contentletAPI = APILocator.getContentletAPI();
+
+ESSearchResults results = contentletAPI.esSearch(query, false, user, false);
+for (Object obj : results) {
+ Contentlet c = (Contentlet) obj;
+ // ...
+}
+
+SearchResponse raw = contentletAPI.esSearchRaw(query, false, user, false);
+SearchHit[] hits = raw.getHits().getHits();
+
+// After
+ContentSearchResults results = contentletAPI.search(query, false, user, false);
+for (Contentlet c : results) {
+ // no cast needed
+}
+
+ContentSearchResponse raw = contentletAPI.searchRaw(query, false, user, false);
+List hits = raw.hits().hits(); // neutral SearchHit DTO
+```
+
+---
+
+## 2. `ContentletAPIPreHook` / `ContentletAPIPostHook` — deprecated hook methods
+
+If your OSGi plugin implements either hook interface to intercept search calls, the
+following methods are deprecated and will be removed:
+
+### `ContentletAPIPreHook`
+
+| Deprecated | Replacement |
+|-----------|-------------|
+| `boolean esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `boolean search(String query, boolean live, User user, boolean respectFrontendRoles)` |
+| `boolean esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `boolean searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` |
+
+### `ContentletAPIPostHook`
+
+| Deprecated | Replacement |
+|-----------|-------------|
+| `void esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `void search(String query, boolean live, User user, boolean respectFrontendRoles)` |
+| `void esSearchRaw(String esQuery, boolean live, User user, boolean respectFrontendRoles)` | `void searchRaw(String query, boolean live, User user, boolean respectFrontendRoles)` |
+
+Both replacement methods have default no-op implementations in the interface, so you
+only need to override them if you need to intercept those calls.
+
+### Migration example
+
+```java
+// Before
+public class MyPreHook implements ContentletAPIPreHook {
+ @Override
+ public boolean esSearch(String esQuery, boolean live, User user, boolean respectFrontendRoles) {
+ Logger.info(this, "intercepted esSearch: " + esQuery);
+ return true;
+ }
+}
+
+// After
+public class MyPreHook implements ContentletAPIPreHook {
+ @Override
+ public boolean search(String query, boolean live, User user, boolean respectFrontendRoles) {
+ Logger.info(this, "intercepted search: " + query);
+ return true;
+ }
+}
+```
+
+---
+
+## 3. Velocity / VTL — `$ESContent` viewtool
+
+The `$ESContent` viewtool (`ESContentTool`) is available in Velocity templates. Two of
+its methods changed return types in this release.
+
+### `$ESContent.search(query)`
+
+| | Before | After |
+|--|--------|-------|
+| Return type | `ESSearchResults` (extends raw `List`) | `ContentSearchResults` (implements `List`) |
+| Element type | `ContentMap` | `ContentMap` (unchanged) |
+
+**Impact:** Templates that iterate over the result with `#foreach` are unaffected —
+the elements are still `ContentMap` objects with the same properties.
+
+```velocity
+## This continues to work unchanged
+#foreach($content in $ESContent.search($query))
+ $content.title
+#end
+```
+
+Templates that access the result as `ESSearchResults` through a Java helper or cast
+will fail at runtime. Replace with `ContentSearchResults`.
+
+### `$ESContent.raw(query)`
+
+| | Before | After |
+|--|--------|-------|
+| Return type | `org.elasticsearch.action.search.SearchResponse` | `ContentSearchResponse` |
+
+**Impact:** Templates that call `.toString()` on the raw response to obtain ES
+wire-format JSON (e.g. to parse it manually) will receive a different string. The new
+`ContentSearchResponse.toString()` is a Java object representation, not JSON.
+
+```velocity
+## HIGH RISK — if your template does this, it will stop receiving valid JSON
+#set($json = $ESContent.raw($query).toString())
+
+## Use the structured accessors instead
+#set($raw = $ESContent.raw($query))
+#set($hits = $raw.hits().hits())
+#foreach($hit in $hits)
+ $hit.id()
+#end
+```
+
+Useful accessors on `ContentSearchResponse`:
+
+| Method | Description |
+|--------|-------------|
+| `hits()` | Returns `SearchHits` — iterable collection of `SearchHit` |
+| `hits().hits()` | `List` |
+| `hits().totalHits().value()` | Total number of matching documents |
+| `scrollId()` | Scroll ID for paginated requests, or `null` |
+| `tookMillis()` | Query execution time in milliseconds |
+| `aggregations()` | `Map>` — terms aggregations |
+
+---
+
+## 4. Return type change: `ESSearchResults` → `ContentSearchResults`
+
+`ESSearchResults` (package `com.dotcms.content.elasticsearch.business`) is not yet
+removed but is no longer returned by the new API methods. If your plugin declares
+variables of type `ESSearchResults`, update them to `ContentSearchResults`.
+
+```java
+// Before
+ESSearchResults results = (ESSearchResults) contentletAPI.esSearch(query, live, user, roles);
+
+// After
+ContentSearchResults results = contentletAPI.search(query, live, user, roles);
+```
+
+The key structural difference:
+
+| | `ESSearchResults` | `ContentSearchResults` |
+|--|-------------------|-----------------------------|
+| Implements | `List` (raw) | `List` (typed) |
+| Element access | Requires `(Contentlet)` cast | Type-safe, no cast needed |
+| Response metadata | `getResponse()` → `SearchResponse` (ES) | `getResponse()` → `ContentSearchResponse` (neutral) |
+| Total results | `getResponse().getHits().getTotalHits().value` | `getTotalResults()` |
+| Scroll ID | `getResponse().getScrollId()` | `getScrollId()` |
+
+---
+
+## 5. Summary of new neutral DTOs
+
+These classes replace the Elasticsearch-specific types in the public API:
+
+| Old type (ES-specific) | New type (neutral) | Package |
+|------------------------|--------------------|---------|
+| `org.elasticsearch.action.search.SearchResponse` | `ContentSearchResponse` | `com.dotcms.content.index.domain` |
+| `com.dotcms.content.elasticsearch.business.ESSearchResults` | `ContentSearchResults` | `com.dotcms.content.index.domain` |
+| `org.elasticsearch.search.SearchHits` | `SearchHits` | `com.dotcms.content.index.domain` |
+| `org.elasticsearch.search.SearchHit` | `SearchHit` | `com.dotcms.content.index.domain` |
+| `org.elasticsearch.search.TotalHits` | `TotalHits` | `com.dotcms.content.index.domain` |
+
+---
+
+## 6. Timeline
+
+| Phase | Action |
+|-------|--------|
+| Now (this release) | `esSearch` / `esSearchRaw` marked `@Deprecated(forRemoval = true)`. New `search` / `searchRaw` methods available. |
+| OpenSearch cutover | `esSearch`, `esSearchRaw`, and ES-specific return types removed. Plugins that have not migrated will **fail to compile**. |
+
+Migrate as soon as possible to get the full migration window.
diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
index cb2aadd8a0d3..99b2cea2b0ce 100644
--- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java
@@ -7,7 +7,7 @@
import com.dotcms.content.business.json.ContentletJsonAPI;
import com.dotcms.contenttype.model.type.BaseContentType;
import com.dotcms.contenttype.model.type.ContentType;
-import com.dotcms.enterprise.ESSeachAPI;
+import com.dotcms.content.index.SearchAPI;
import com.dotcms.uuid.shorty.ShortyIdAPI;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.APILocator;
@@ -803,7 +803,7 @@ private int countApproximateClausesInQuery(String query) {
*/
private Set processSingleESQuery(final BrowserQuery browserQuery, final Set inodes, final long startTime) {
final boolean live = !browserQuery.showWorking;
- final ESSeachAPI esSearchAPI = APILocator.getEsSearchAPI();
+ final SearchAPI searchAPI = APILocator.getSearchAPI();
final List collectedInodes = new ArrayList<>();
try {
@@ -815,7 +815,7 @@ private Set processSingleESQuery(final BrowserQuery browserQuery, final
Logger.debug(this, String.format("Single ES query: %d inodes", inodes.size()));
- esSearchAPI.esSearch(esQuery, live, browserQuery.user, false).forEach(result -> {
+ searchAPI.search(esQuery, live, browserQuery.user, false).forEach(result -> {
final Contentlet contentlet = (Contentlet) result;
collectedInodes.add(contentlet.getInode());
});
diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
index 14f321d47e39..8a8a44b64135 100644
--- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
@@ -235,8 +235,8 @@
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.elasticsearch.action.search.SearchPhaseExecutionException;
+import com.dotcms.content.index.domain.ContentSearchResponse;
import org.elasticsearch.action.search.SearchResponse;
-import org.elasticsearch.search.SearchHit;
/**
@@ -348,12 +348,14 @@ private static UniqueFieldValidationStrategyResolver getUniqueFieldValidationStr
return CDIUtils.getBeanThrows(UniqueFieldValidationStrategyResolver.class);
}
+ @Deprecated(forRemoval = true)
@Override
public SearchResponse esSearchRaw(String esQuery, boolean live, User user,
boolean respectFrontendRoles) throws DotSecurityException, DotDataException {
return APILocator.getEsSearchAPI().esSearchRaw(esQuery, live, user, respectFrontendRoles);
}
+ @Deprecated(forRemoval = true)
@Override
public ESSearchResults esSearch(String esQuery, boolean live, User user,
boolean respectFrontendRoles) throws DotSecurityException, DotDataException {
@@ -2381,29 +2383,29 @@ private List getRelatedChildren(final Contentlet contentlet, final R
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;
- SearchResponse response;
+ ContentSearchResponse response;
final boolean DONT_PULL_PARENTS = Boolean.FALSE;
//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
//Search for related content in other versions of the same contentlet
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet.getIdentifier(), relationshipName,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet.getIdentifier(), relationshipName,
DONT_PULL_PARENTS, WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}
- if (response.getHits() == null) {
+ if (response.hits().hits().isEmpty()) {
return result;
}
- for (final SearchHit sh : response.getHits()) {
- final Map sourceMap = sh.getSourceAsMap();
+ for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
+ final Map sourceMap = sh.sourceAsMap();
if (sourceMap.get(relationshipName) != null) {
List relatedIdentifiers = ((ArrayList) sourceMap.get(
relationshipName));
@@ -2475,25 +2477,25 @@ private List getRelatedParents(final Contentlet contentlet, final Re
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;
- SearchResponse response;
+ ContentSearchResponse response;
final boolean PULL_PARENTS = Boolean.TRUE;
//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet, relationshipName, PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet, relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}
- if (response.getHits() != null) {
- for (final SearchHit sh : response.getHits()) {
- final Map sourceMap = sh.getSourceAsMap();
+ if (!response.hits().hits().isEmpty()) {
+ for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
+ final Map sourceMap = sh.sourceAsMap();
final String identifier = (String) sourceMap.get("identifier");
if (identifier != null && !relatedMap.containsKey(identifier)) {
final Contentlet mappedContentlet = findContentletByIdentifierAnyLanguage(
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java
new file mode 100644
index 000000000000..a30b5c228eed
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java
@@ -0,0 +1,153 @@
+package com.dotcms.content.index;
+
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.liferay.portal.model.User;
+
+/**
+ * Vendor-neutral search API for executing full-text index queries.
+ *
+ *
This interface replaces the legacy {@code ESSeachAPI} whose method signatures
+ * leaked {@code org.elasticsearch.action.search.SearchResponse} and
+ * {@code ESSearchResults} into application code. Implementations delegate to the
+ * active search back-end (Elasticsearch or OpenSearch) as determined by the current
+ * migration phase.
+ *
+ *
All methods accept the same Lucene/JSON query format used by the existing search
+ * paths, so call sites only need to change the method names and return types.
+ *
+ * @see SearchAPIImpl Phase-aware router (register via {@code APILocator.getSearchAPI()})
+ */
+public interface SearchAPI {
+
+ /**
+ * Executes a JSON search query and returns the matching contentlets loaded from the DB.
+ *
+ *
Equivalent to {@code ESSeachAPI.esSearch()} but returns a vendor-neutral
+ * {@link ContentSearchResults} instead of {@code ESSearchResults}.
+ *
+ * @param query the JSON search query
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action (may be {@code null} when
+ * {@code respectFrontendRoles} is {@code true})
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @return populated result list; never {@code null}
+ */
+ ContentSearchResults search(
+ String query,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException;
+
+ /**
+ * Executes a raw JSON search query and returns the index response without loading
+ * contentlets from the database.
+ *
+ *
Equivalent to {@code ESSeachAPI.esSearchRaw()} but returns a vendor-neutral
+ * {@link ContentSearchResponse} instead of {@code SearchResponse}.
+ *
+ * @param query the JSON search query
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @return raw search response; never {@code null}
+ */
+ ContentSearchResponse searchRaw(
+ String query,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException;
+
+ /**
+ * Returns related content for a given contentlet identifier (no pagination).
+ *
+ * @param contentletIdentifier identifier of the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ */
+ ContentSearchResponse searchRelated(
+ String contentletIdentifier,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns related content for a given contentlet (no pagination).
+ *
+ * @param contentlet the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ */
+ ContentSearchResponse searchRelated(
+ Contentlet contentlet,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns paginated related content for a given contentlet identifier.
+ *
+ * @param contentletIdentifier identifier of the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @param limit maximum number of results ({@code -1} for no limit)
+ * @param offset result offset for pagination
+ * @param sortBy sort expression, or {@code null} for default sort
+ */
+ ContentSearchResponse searchRelated(
+ String contentletIdentifier,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles,
+ int limit,
+ int offset,
+ String sortBy)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns paginated related content for a given contentlet.
+ *
+ * @param contentlet the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @param limit maximum number of results ({@code -1} for no limit)
+ * @param offset result offset for pagination
+ * @param sortBy sort expression, or {@code null} for default sort
+ */
+ ContentSearchResponse searchRelated(
+ Contentlet contentlet,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles,
+ int limit,
+ int offset,
+ String sortBy)
+ throws DotDataException, DotSecurityException;
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
new file mode 100644
index 000000000000..3b1bdc2a7194
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
@@ -0,0 +1,186 @@
+package com.dotcms.content.index;
+
+import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotcms.content.index.elasticsearch.ESSearchAPIImpl;
+import com.dotcms.content.index.opensearch.OSSearchAPIImpl;
+import com.dotcms.content.model.annotation.IndexLibraryIndependent;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.liferay.portal.model.User;
+
+/**
+ * Phase-aware router implementation of {@link SearchAPI}.
+ *
+ *
Routing table
+ *
+ * Phase | Read provider
+ * --------------------------|---------------
+ * 0 — not started | ES
+ * 1 — dual-write, ES reads | ES
+ * 2 — dual-write, OS reads | OS (with ES fallback on failure)
+ * 3 — OS only | OS
+ *
+ *
+ *
All search operations are reads; there are no write operations in this API, so the
+ * router only delegates to the read-path provider determined by {@link PhaseRouter#read}.
+ *
+ * @see PhaseRouter
+ * @see ESSearchAPIImpl
+ * @see OSSearchAPIImpl
+ */
+@IndexLibraryIndependent
+public class SearchAPIImpl implements SearchAPI {
+
+ private final ESSearchAPIImpl esImpl;
+ private final OSSearchAPIImpl osImpl;
+ private final PhaseRouter router;
+
+ public SearchAPIImpl() {
+ this(CDIUtils.getBeanThrows(ESSearchAPIImpl.class),
+ CDIUtils.getBeanThrows(OSSearchAPIImpl.class));
+ }
+
+ /** Package-private constructor for testing. */
+ SearchAPIImpl(final ESSearchAPIImpl esImpl, final OSSearchAPIImpl osImpl) {
+ this.esImpl = esImpl;
+ this.osImpl = osImpl;
+ this.router = new PhaseRouter<>(esImpl, osImpl);
+ }
+
+ /** Direct access to the ES implementation (for testing / bootstrap checks). */
+ public ESSearchAPIImpl esImpl() {
+ return esImpl;
+ }
+
+ /** Direct access to the OS implementation (for testing / bootstrap checks). */
+ public OSSearchAPIImpl osImpl() {
+ return osImpl;
+ }
+
+ // -------------------------------------------------------------------------
+ // SearchAPI — all operations are reads; delegate via router.readChecked
+ // -------------------------------------------------------------------------
+
+ @Override
+ public ContentSearchResults search(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+ try {
+ return router.readChecked(impl ->
+ impl.search(query, live, user, respectFrontendRoles));
+ } catch (final DotSecurityException | DotDataException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRaw(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+ try {
+ return router.readChecked(impl ->
+ impl.searchRaw(query, live, user, respectFrontendRoles));
+ } catch (final DotSecurityException | DotDataException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentletIdentifier, relationshipName, pullParents,
+ live, user, respectFrontendRoles));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentlet, relationshipName, pullParents,
+ live, user, respectFrontendRoles));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentletIdentifier, relationshipName, pullParents,
+ live, user, respectFrontendRoles, limit, offset, sortBy));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentlet, relationshipName, pullParents,
+ live, user, respectFrontendRoles, limit, offset, sortBy));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java b/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java
new file mode 100644
index 000000000000..cb7c21497fce
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java
@@ -0,0 +1,70 @@
+package com.dotcms.content.index.domain;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import org.immutables.value.Value;
+
+/**
+ * Vendor-neutral representation of a single bucket in a terms aggregation.
+ *
+ *
Replaces direct use of {@code org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket}
+ * and {@code org.opensearch.client.opensearch._types.aggregations.StringTermsBucket} in
+ * application code.
+ */
+@Value.Immutable
+public interface AggregationBucket {
+
+ /** Bucket key as a String (numeric keys are converted via {@code toString()}). */
+ String key();
+
+ /** Number of documents in this bucket. */
+ long docCount();
+
+ static ImmutableAggregationBucket.Builder builder() {
+ return ImmutableAggregationBucket.builder();
+ }
+
+ // -------------------------------------------------------------------------
+ // ES factories
+ // -------------------------------------------------------------------------
+
+ /** Creates a bucket from an Elasticsearch terms bucket. */
+ static AggregationBucket from(
+ final org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket esBucket) {
+ return builder()
+ .key(esBucket.getKeyAsString())
+ .docCount(esBucket.getDocCount())
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // OS factories
+ // -------------------------------------------------------------------------
+
+ /** Creates a bucket from an OpenSearch string-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.StringTermsBucket osBucket) {
+ return builder()
+ .key(osBucket.key())
+ .docCount(osBucket.docCount())
+ .build();
+ }
+
+ /** Creates a bucket from an OpenSearch long-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.LongTermsBucket osBucket) {
+ return builder()
+ .key(String.valueOf(osBucket.key()))
+ .docCount(osBucket.docCount())
+ .build();
+ }
+
+ /** Creates a bucket from an OpenSearch double-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.DoubleTermsBucket osBucket) {
+ return builder()
+ .key(String.valueOf(osBucket.key()))
+ .docCount(osBucket.docCount())
+ .build();
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java b/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java
new file mode 100644
index 000000000000..6d15f3d437e9
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java
@@ -0,0 +1,119 @@
+package com.dotcms.content.index.domain;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.immutables.value.Value;
+
+/**
+ * Vendor-neutral representation of a raw search response.
+ *
+ *
Replaces direct use of {@code org.elasticsearch.action.search.SearchResponse} in
+ * application code. Carries the search hits, timing metadata, scroll ID, and
+ * the first-level terms aggregations (the only aggregation type used in dotCMS).
+ *
+ *
Factory methods ({@code from(ES)}, {@code from(OS)}) map vendor types to this
+ * neutral DTO; they are the only places where vendor imports are allowed in this file.
+ */
+@Value.Immutable
+public interface ContentSearchResponse {
+
+ /** Neutral search hits (already vendor-independent). */
+ SearchHits hits();
+
+ /**
+ * Scroll ID returned by the cluster, or {@code null} when not a scroll request.
+ * ES: {@code SearchResponse.getScrollId()} / OS: {@code SearchResponse.scrollId()}
+ */
+ @Nullable
+ String scrollId();
+
+ /** Time the cluster took to execute the query, in milliseconds. */
+ long tookMillis();
+
+ /**
+ * First-level terms aggregations, keyed by aggregation name.
+ * Only {@code terms} aggregations are mapped; other types are silently skipped.
+ */
+ @Value.Default
+ default Map> aggregations() {
+ return Collections.emptyMap();
+ }
+
+ static ImmutableContentSearchResponse.Builder builder() {
+ return ImmutableContentSearchResponse.builder();
+ }
+
+ // -------------------------------------------------------------------------
+ // ES factory
+ // -------------------------------------------------------------------------
+
+ static ContentSearchResponse from(
+ final org.elasticsearch.action.search.SearchResponse esResponse) {
+
+ final Map> aggs = new LinkedHashMap<>();
+ if (esResponse.getAggregations() != null) {
+ for (final org.elasticsearch.search.aggregations.Aggregation agg
+ : esResponse.getAggregations().asList()) {
+ if (agg instanceof org.elasticsearch.search.aggregations.bucket.terms.Terms) {
+ final org.elasticsearch.search.aggregations.bucket.terms.Terms termAgg =
+ (org.elasticsearch.search.aggregations.bucket.terms.Terms) agg;
+ aggs.put(agg.getName(), termAgg.getBuckets().stream()
+ .map(AggregationBucket::from)
+ .collect(Collectors.toList()));
+ }
+ }
+ }
+
+ return builder()
+ .hits(esResponse.getHits() != null
+ ? SearchHits.from(esResponse.getHits())
+ : SearchHits.empty())
+ .scrollId(esResponse.getScrollId())
+ .tookMillis(esResponse.getTook() != null ? esResponse.getTook().getMillis() : 0L)
+ .aggregations(aggs)
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // OS factory
+ // -------------------------------------------------------------------------
+
+ static ContentSearchResponse from(
+ final org.opensearch.client.opensearch.core.SearchResponse
+ *
+ * @author Fabrizzio Araya
+ */
+@ApplicationScoped
+@RunWith(DataProviderWeldRunner.class)
+public class OSSearchAPIImplIntegrationTest extends IntegrationTestBase {
+
+ private static final String RUN_ID =
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+
+ private static final String IDX_LIVE = "live_search_" + RUN_ID;
+ private static final String IDX_WORKING = "working_search_" + RUN_ID;
+
+ /** Unique identifier and inode for the document indexed in _source rewrite tests. */
+ private static final String TEST_DOC_IDENTIFIER = "test-identifier-" + RUN_ID;
+ private static final String TEST_DOC_INODE = "test-inode-" + RUN_ID;
+ private static final String TEST_DOC_ID = TEST_DOC_IDENTIFIER + "_1_default";
+ private static final String TEST_DOC_JSON =
+ "{\"identifier\":\"" + TEST_DOC_IDENTIFIER + "\","
+ + "\"inode\":\"" + TEST_DOC_INODE + "\","
+ + "\"title\":\"OSSearchAPIImpl source-rewrite test\","
+ + "\"language_id\":1,"
+ + "\"contenttype\":\"testtype\"}";
+
+ /**
+ * The version constant used by {@link VersionedIndicesAPI#loadDefaultVersionedIndices()}.
+ * Test indices must be registered under this version for {@code OSSearchAPIImpl.resolveIndex()}
+ * to find them.
+ */
+ private static final String DEFAULT_OS_VERSION = VersionedIndices.OPENSEARCH_3X;
+
+ // ── CDI-injected beans ──────────────────────────────────────────────────
+ @Inject
+ private OSSearchAPIImpl osSearchAPI;
+
+ @Inject
+ private OSIndexAPIImpl osIndexAPI;
+
+ @Inject
+ private ContentletIndexOperationsOS opsOS;
+
+ @Inject
+ private OSClientProvider clientProvider;
+
+ @Inject
+ private VersionedIndicesAPI versionedIndicesAPI;
+
+ private User systemUser;
+
+ // =======================================================================
+ // Lifecycle
+ // =======================================================================
+
+ @BeforeClass
+ public static void prepare() throws Exception {
+ IntegrationTestInitService.getInstance().init();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ cleanupTestOsIndices();
+ cleanupVersionedRows();
+
+ systemUser = APILocator.getUserAPI().getSystemUser();
+
+ // Create OS indices and register them under the default OPENSEARCH_3X version
+ // so resolveIndex() (which calls loadDefaultVersionedIndices()) finds them.
+ osIndexAPI.createIndex(IDX_LIVE, 1);
+ osIndexAPI.createIndex(IDX_WORKING, 1);
+
+ // createIndex() stores the cluster-prefixed name in OpenSearch; resolveIndex()
+ // must use the same prefixed name, otherwise the search hits a non-existent index.
+ final String fullLive = osIndexAPI.getNameWithClusterIDPrefix(IDX_LIVE);
+ final String fullWorking = osIndexAPI.getNameWithClusterIDPrefix(IDX_WORKING);
+
+ versionedIndicesAPI.saveIndices(
+ VersionedIndicesImpl.builder()
+ .version(DEFAULT_OS_VERSION)
+ .live(fullLive)
+ .working(fullWorking)
+ .build());
+
+ // Ensure resolveIndex() reads the freshly saved rows, not a cached prior state.
+ versionedIndicesAPI.clearCache();
+ }
+
+ @After
+ public void tearDown() {
+ cleanupTestOsIndices();
+ cleanupVersionedRows();
+ }
+
+ // =======================================================================
+ // Tests – searchRaw
+ // =======================================================================
+
+ /**
+ * Given scenario: A valid match-all JSON query against an empty working index.
+ * Expected: {@link OSSearchAPIImpl#searchRaw} returns a non-null {@link ContentSearchResponse}
+ * with a non-null {@link com.dotcms.content.index.domain.SearchHits} and zero total hits.
+ */
+ @Test
+ public void test_searchRaw_matchAll_shouldReturnEmptyResultsWithNonNullStructure()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(matchAll, false, systemUser, false);
+
+ assertNotNull("searchRaw must return a non-null ContentSearchResponse", response);
+ assertNotNull("hits must not be null on an empty-index response", response.hits());
+ assertNotNull("aggregations map must not be null", response.aggregations());
+ Logger.info(this,
+ "✅ test_searchRaw_matchAll_shouldReturnEmptyResultsWithNonNullStructure passed"
+ + " – totalHits=" + response.hits().totalHits().value());
+ }
+
+ /**
+ * Given scenario: A terms aggregation query against an empty working index.
+ * Expected: {@link ContentSearchResponse#aggregations()} contains the key {@code "entries"}
+ * (from the JSON {@code "aggs": {"entries": {"terms": ...}}}) and that bucket list
+ * is empty (no documents).
+ */
+ @Test
+ public void test_searchRaw_withTermsAgg_shouldReturnAggregationKey() throws Exception {
+ final String aggQuery =
+ "{\"size\":0,\"aggs\":{\"entries\":{\"terms\":{\"field\":\"contenttype_dotraw\",\"size\":100}}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(aggQuery, false, systemUser, false);
+
+ assertNotNull("searchRaw must return a non-null response", response);
+ assertTrue(
+ "aggregations map must contain the 'entries' key even when result is empty",
+ response.aggregations().containsKey("entries"));
+ assertTrue("entries bucket must be empty for an empty index",
+ response.aggregations().get("entries").isEmpty());
+ Logger.info(this,
+ "✅ test_searchRaw_withTermsAgg_shouldReturnAggregationKey passed");
+ }
+
+ /**
+ * Given scenario: A match-all query against the live index.
+ * Expected: No exception is thrown and a valid response is returned.
+ */
+ @Test
+ public void test_searchRaw_liveIndex_shouldNotThrow() throws Exception {
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(matchAll, true, systemUser, false);
+
+ assertNotNull("searchRaw against live index must return non-null response", response);
+ assertNotNull("hits must be non-null for live index query", response.hits());
+ Logger.info(this, "✅ test_searchRaw_liveIndex_shouldNotThrow passed");
+ }
+
+ /**
+ * Given scenario: A null query string is passed to {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: A {@link com.dotmarketing.business.DotStateException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.business.DotStateException.class)
+ public void test_searchRaw_nullQuery_shouldThrowDotStateException() throws Exception {
+ osSearchAPI.searchRaw(null, false, systemUser, false);
+ }
+
+ /**
+ * Given scenario: An empty string query is passed to {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: A {@link com.dotmarketing.business.DotStateException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.business.DotStateException.class)
+ public void test_searchRaw_emptyQuery_shouldThrowDotStateException() throws Exception {
+ osSearchAPI.searchRaw("", false, systemUser, false);
+ }
+
+ /**
+ * Given scenario: {@code user} is {@code null} and {@code respectFrontendRoles} is false.
+ * Expected: A {@link com.dotmarketing.exception.DotSecurityException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.exception.DotSecurityException.class)
+ public void test_searchRaw_nullUserNoFrontendRoles_shouldThrowDotSecurityException()
+ throws Exception {
+ osSearchAPI.searchRaw("{\"query\":{\"match_all\":{}}}", false, null, false);
+ }
+
+ // =======================================================================
+ // Tests – search (full contentlet population)
+ // =======================================================================
+
+ /**
+ * Given scenario: A match-all JSON query with no documents in the index.
+ * Expected: {@link OSSearchAPIImpl#search} returns a non-null {@link ContentSearchResults}
+ * that is empty and reports zero total hits.
+ */
+ @Test
+ public void test_search_matchAll_emptyIndex_shouldReturnEmptyContentSearchResults()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResults results =
+ osSearchAPI.search(matchAll, false, systemUser, false);
+
+ assertNotNull("search must return non-null ContentSearchResults", results);
+ assertTrue("ContentSearchResults must be empty when index has no documents",
+ results.isEmpty());
+ assertNotNull("getResponse must return non-null ContentSearchResponse",
+ results.getResponse());
+ Logger.info(this,
+ "✅ test_search_matchAll_emptyIndex_shouldReturnEmptyContentSearchResults passed");
+ }
+
+ /**
+ * Given scenario: A match-all query is run with {@code respectFrontendRoles=true} and a null
+ * user — the anonymous-user path.
+ * Expected: No security exception; an empty but valid result is returned.
+ */
+ @Test
+ public void test_search_respectFrontendRoles_nullUser_shouldNotThrow() throws Exception {
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResults results =
+ osSearchAPI.search(matchAll, false, null, true);
+
+ assertNotNull("search with respectFrontendRoles=true must return non-null results", results);
+ Logger.info(this,
+ "✅ test_search_respectFrontendRoles_nullUser_shouldNotThrow passed");
+ }
+
+ // =======================================================================
+ // Tests – pagination
+ // =======================================================================
+
+ /**
+ * Given scenario: A search with explicit limit and offset against an empty index.
+ * Expected: No exception; result is empty and response structure is valid.
+ */
+ @Test
+ public void test_searchRelated_withLimitAndOffset_emptyIndex_shouldReturnValidStructure()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+ // Use a fake identifier/inode so the method constructs the query and fires against OS
+ // (it will find 0 results because the index is empty)
+ final com.dotmarketing.portlets.contentlet.model.Contentlet fakeContent =
+ new com.dotmarketing.portlets.contentlet.model.Contentlet();
+ fakeContent.setIdentifier("fake-id-" + RUN_ID);
+ fakeContent.setInode("fake-inode-" + RUN_ID);
+
+ final ContentSearchResponse response = osSearchAPI.searchRelated(
+ fakeContent, "fakeRelationship", false, false, systemUser, false, 10, 0, null);
+
+ assertNotNull("searchRelated must return non-null response", response);
+ assertNotNull("hits must be non-null", response.hits());
+ Logger.info(this,
+ "✅ test_searchRelated_withLimitAndOffset_emptyIndex_shouldReturnValidStructure passed");
+ }
+
+ // =======================================================================
+ // Tests – _source rewrite (identifier + inode always present)
+ // =======================================================================
+
+ /**
+ * Given scenario: A document with both {@code identifier} and {@code inode} fields is indexed.
+ * A plain match-all query (no {@code _source} clause) is executed via
+ * {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: {@link OSSearchAPIImpl} rewrites the query to {@code "_source":[identifier,inode]}
+ * and both fields are returned in the hit's {@code sourceAsMap}.
+ */
+ @Test
+ public void test_searchRaw_withIndexedDocument_sourceRewrite_shouldIncludeIdentifierAndInode()
+ throws Exception {
+
+ final String fullWorking = osIndexAPI.getNameWithClusterIDPrefix(IDX_WORKING);
+ indexTestDocument(fullWorking);
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(matchAll, false, systemUser, false);
+
+ assertNotNull("searchRaw must return a non-null response", response);
+ assertEquals("Exactly one hit expected", 1, response.hits().totalHits().value());
+
+ final com.dotcms.content.index.domain.SearchHit hit =
+ response.hits().iterator().next();
+ assertEquals("identifier must be present in sourceAsMap",
+ TEST_DOC_IDENTIFIER, hit.sourceAsMap().get("identifier"));
+ assertEquals("inode must be present in sourceAsMap — _source rewrite must include it",
+ TEST_DOC_INODE, hit.sourceAsMap().get("inode"));
+
+ Logger.info(this, "✅ test_searchRaw_withIndexedDocument_sourceRewrite_shouldIncludeIdentifierAndInode passed");
+ }
+
+ /**
+ * Given scenario: A document with both {@code identifier} and {@code inode} fields is indexed.
+ * A query that explicitly restricts {@code _source} to only {@code ["identifier"]}
+ * is executed via {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: The impl overwrites the caller's {@code _source} with {@code [identifier, inode]},
+ * so {@code inode} is still present in the hit's {@code sourceAsMap} despite not
+ * being requested by the caller.
+ */
+ @Test
+ public void test_searchRaw_userDefinedSource_isOverwrittenToIncludeInode()
+ throws Exception {
+
+ final String fullWorking = osIndexAPI.getNameWithClusterIDPrefix(IDX_WORKING);
+ indexTestDocument(fullWorking);
+
+ // Caller requests only identifier — inode intentionally omitted.
+ final String queryWithSource =
+ "{\"query\":{\"match_all\":{}},\"_source\":[\"identifier\"]}";
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(queryWithSource, false, systemUser, false);
+
+ assertNotNull(response);
+ assertTrue("Response must have at least one hit", response.hits().totalHits().value() > 0);
+
+ final com.dotcms.content.index.domain.SearchHit hit =
+ response.hits().iterator().next();
+ assertNotNull("inode must still be present after _source overwrite",
+ hit.sourceAsMap().get("inode"));
+
+ Logger.info(this, "✅ test_searchRaw_userDefinedSource_isOverwrittenToIncludeInode passed");
+ }
+
+ // =======================================================================
+ // Helpers
+ // =======================================================================
+
+ /**
+ * Indexes {@link #TEST_DOC_JSON} into the given index and refreshes it so the
+ * document is immediately visible to searches.
+ */
+ private void indexTestDocument(final String fullIndexName) throws Exception {
+ final IndexBulkRequest req = opsOS.createBulkRequest();
+ opsOS.addIndexOp(req, fullIndexName, TEST_DOC_ID, TEST_DOC_JSON);
+ opsOS.putToIndex(req);
+ refreshTestIndex(fullIndexName);
+ }
+
+ private void refreshTestIndex(final String fullIndexName) {
+ try {
+ final OpenSearchClient client = clientProvider.getClient();
+ client.indices().refresh(RefreshRequest.of(r -> r.index(fullIndexName)));
+ } catch (final Exception e) {
+ Logger.warn(this, "refreshTestIndex: error refreshing '" + fullIndexName
+ + "': " + e.getMessage());
+ }
+ }
+
+ private synchronized void cleanupTestOsIndices() {
+ for (final String idx : List.of(IDX_LIVE, IDX_WORKING)) {
+ try {
+ if (osIndexAPI.indexExists(idx)) {
+ osIndexAPI.delete(idx);
+ }
+ } catch (Exception e) {
+ Logger.warn(this, "Cleanup: error removing OS index '" + idx + "': " + e.getMessage());
+ }
+ }
+ }
+
+ private void cleanupVersionedRows() {
+ try {
+ // Remove only the test-scoped index entries by their RUN_ID-tagged index names.
+ // Deleting by index_name avoids removing the entire OPENSEARCH_3X version row,
+ // which would break other tests running in parallel.
+ new DotConnect()
+ .setSQL("DELETE FROM indicies WHERE index_name LIKE ?")
+ .addParam("%" + RUN_ID + "%")
+ .loadResult();
+ } catch (Exception e) {
+ Logger.warn(this, "Cleanup: error removing versioned DB rows: " + e.getMessage());
+ }
+ }
+
+}
diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImplTest.java
index 8de5ac5a8e9c..c0cf0253a874 100644
--- a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImplTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/ContentTypeDestroyAPIImplTest.java
@@ -3,7 +3,7 @@
import com.dotcms.IntegrationTestBase;
import com.dotcms.JUnit4WeldRunner;
import com.dotcms.business.CloseDBIfOpened;
-import com.dotcms.content.elasticsearch.business.ESSearchResults;
+import com.dotcms.content.index.domain.ContentSearchResults;
import com.dotcms.contenttype.business.ContentTypeDestroyAPIImpl.ContentletVersionInfo;
import com.dotcms.contenttype.model.field.Field;
import com.dotcms.contenttype.model.type.ContentType;
@@ -149,7 +149,7 @@ public void Destroy_General_Test() throws DotDataException, DotSecurityException
Assert.assertEquals(0, count);
final Set inodes = contentTypeAndInode._2();
- //Test no content is left hang around
+ //Test no content is left to hang around
for (String inode:inodes) {
count = new DotConnect().setSQL("select count(*) as x from contentlet where inode = ? ").addParam(inode).getInt("x");
Assert.assertEquals(0, count);
@@ -164,7 +164,7 @@ public void Destroy_General_Test() throws DotDataException, DotSecurityException
Assert.assertTrue(searchIndex(anyInode.get(), false).isEmpty());
}
- private ESSearchResults searchIndex(final ContentType contentType, final boolean live )
+ private ContentSearchResults searchIndex(final ContentType contentType, final boolean live )
throws DotDataException, DotSecurityException {
final String esQuery = String.format("{\n"
@@ -175,12 +175,12 @@ private ESSearchResults searchIndex(final ContentType contentType, final boolean
+ " }\n"
+ "}", contentType.variable());
- return APILocator.getEsSearchAPI()
- .esSearch(esQuery, live, APILocator.systemUser(), false);
+ return APILocator.getSearchAPI()
+ .search(esQuery, live, APILocator.systemUser(), false);
}
- private ESSearchResults searchIndex(final String inode, final boolean live )
+ private ContentSearchResults searchIndex(final String inode, final boolean live )
throws DotDataException, DotSecurityException {
final String esQuery = String.format("{\n"
@@ -191,8 +191,8 @@ private ESSearchResults searchIndex(final String inode, final boolean live )
+ " }\n"
+ "}", inode);
- return APILocator.getEsSearchAPI()
- .esSearch(esQuery, live, APILocator.systemUser(), false);
+ return APILocator.getSearchAPI()
+ .search(esQuery, live, APILocator.systemUser(), false);
}
/**
* Given Scenario: We create a Content Type with a relationship field
diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/page/PageResourceTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/page/PageResourceTest.java
index 7b084786c56e..ab5d0065b683 100644
--- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/page/PageResourceTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/page/PageResourceTest.java
@@ -24,7 +24,9 @@
import com.dotcms.JUnit4WeldRunner;
import com.dotcms.api.web.HttpServletRequestThreadLocal;
import com.dotcms.api.web.HttpServletResponseThreadLocal;
-import com.dotcms.content.elasticsearch.business.ESSearchResults;
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotcms.content.index.domain.SearchHits;
import com.dotcms.contenttype.business.ContentTypeAPI;
import com.dotcms.contenttype.model.field.TextField;
import com.dotcms.contenttype.model.type.ContentType;
@@ -137,7 +139,6 @@
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.ws.rs.core.Response;
-import org.elasticsearch.action.search.SearchResponse;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -563,12 +564,12 @@ public void testPathParam()
throws DotSecurityException, DotDataException {
final String path = pagePath;
- final SearchResponse searchResponse = mock(SearchResponse.class);
-
final Contentlet contentlet = pageAsset;
final List contentlets = list(contentlet);
- final ESSearchResults results = new ESSearchResults(searchResponse, contentlets);
+ final ContentSearchResults results = new ContentSearchResults<>(
+ ContentSearchResponse.builder().hits(SearchHits.empty()).tookMillis(0).build(),
+ contentlets);
final String query = String.format("{"
+ "query: {"
+ "query_string: {"
@@ -578,7 +579,7 @@ public void testPathParam()
+ "}", path.replace("/", "\\\\/"));
- when(esapi.esSearch(query, false, user, false)).thenReturn(results);
+ when(esapi.search(query, false, user, false)).thenReturn(results);
final Response response = pageResource.searchPage(request, new EmptyHttpResponse(), path, false, true);
RestUtilTest.verifySuccessResponse(response);
@@ -603,10 +604,10 @@ public void testPathParamWithHost()
throws DotSecurityException, DotDataException {
final String path = String.format("//%s/%s/%s", hostName, folderName, pageName);
- final SearchResponse searchResponse = mock(SearchResponse.class);
-
final List contentlets = list(pageAsset);
- final ESSearchResults results = new ESSearchResults(searchResponse, contentlets);
+ final ContentSearchResults results = new ContentSearchResults<>(
+ ContentSearchResponse.builder().hits(SearchHits.empty()).tookMillis(0).build(),
+ contentlets);
String preparedPagePath = String.format("%s/%s",folderName,pageName).replace("/", "\\\\/");
final String query = String.format("{"
+ "query: {"
@@ -616,7 +617,7 @@ public void testPathParamWithHost()
+ "}"
+ "}", preparedPagePath, host.getHostname());
- when(esapi.esSearch(query, false, user, false)).thenReturn(results);
+ when(esapi.search(query, false, user, false)).thenReturn(results);
final Response response = pageResource.searchPage(request, new EmptyHttpResponse(), path, false, true);
RestUtilTest.verifySuccessResponse(response);
diff --git a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java
index eb979795b51c..4331138f46f9 100644
--- a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java
+++ b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java
@@ -191,7 +191,7 @@
import org.apache.velocity.context.InternalContextAdapterImpl;
import org.apache.velocity.runtime.parser.node.SimpleNode;
import org.awaitility.Awaitility;
-import org.elasticsearch.action.search.SearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResponse;
import org.jetbrains.annotations.NotNull;
import org.junit.Assert;
import org.junit.Ignore;
@@ -8305,7 +8305,7 @@ public void test_variant_present_in_document_id() throws Exception {
+ " },"
+ "}";
- final SearchResponse responseDefaultVariant = APILocator.getContentletAPI().esSearchRaw(
+ final ContentSearchResponse responseDefaultVariant = APILocator.getContentletAPI().searchRaw(
StringUtils.lowercaseStringExceptMatchingTokens(queryContentOnDefaultVariant,
ESContentFactoryImpl.LUCENE_RESERVED_KEYWORDS_REGEX),
false, APILocator.systemUser(), false);
@@ -8313,7 +8313,7 @@ public void test_variant_present_in_document_id() throws Exception {
assertEquals(contentDefaultVariant.getIdentifier() + "_"
+ contentDefaultVariant.getLanguageId() + "_"
+ contentDefaultVariant.getVariantId(),
- responseDefaultVariant.getHits().iterator().next().getId());
+ responseDefaultVariant.hits().iterator().next().id());
final String queryContentOnNewVariant = "{"
+ "query: {"
@@ -8323,14 +8323,14 @@ public void test_variant_present_in_document_id() throws Exception {
+ " },"
+ "}";
- final SearchResponse responseNewVariant = APILocator.getContentletAPI().esSearchRaw(
+ final ContentSearchResponse responseNewVariant = APILocator.getContentletAPI().searchRaw(
StringUtils.lowercaseStringExceptMatchingTokens(queryContentOnNewVariant,
ESContentFactoryImpl.LUCENE_RESERVED_KEYWORDS_REGEX),
false, APILocator.systemUser(), false);
assertEquals(contentNewVariant.getIdentifier() + "_"
+ contentNewVariant.getLanguageId() + "_" + contentNewVariant.getVariantId(),
- responseNewVariant.getHits().iterator().next().getId());
+ responseNewVariant.hits().iterator().next().id());
}
@DataProvider